push-oynptmmzrplt #2
76
src/json_extract/__init__.py
Normal file
76
src/json_extract/__init__.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def structure_to_json(text: str, schema: Dict) -> Dict:
|
||||
"""
|
||||
Convert structured text to JSON using LOCAL_LLM_API.
|
||||
|
||||
Args:
|
||||
text (str): The structured text to convert
|
||||
schema (dict): The JSON schema to use for conversion
|
||||
|
||||
Returns:
|
||||
dict: The parsed JSON object
|
||||
"""
|
||||
# Get the API URL from environment variable
|
||||
local_llm_api = os.environ.get("LOCAL_LLM_API", "http://host.docker.internal:1248/v1")
|
||||
|
||||
# Format the schema as JSON string
|
||||
schema_json = json.dumps(schema, indent=2)
|
||||
|
||||
# Create the prompt following the pattern from jj-ai-commit.sh
|
||||
prompt_text = f"""Analyse this text and respond ONLY with a valid JSON object matching the schema.
|
||||
|
||||
Text:
|
||||
{text}
|
||||
|
||||
Schema:
|
||||
{schema_json}"""
|
||||
|
||||
# Build the request body
|
||||
request_body = {
|
||||
"model": "qwen3-coder",
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt_text}
|
||||
]
|
||||
}
|
||||
|
||||
# Build the URL
|
||||
url = f"{local_llm_api}/chat/completions"
|
||||
|
||||
# Create the request
|
||||
data = json.dumps(request_body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
# Execute the request
|
||||
with urllib.request.urlopen(req) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.URLError as e:
|
||||
return {"error": f"API request failed: {e}", "raw_response": ""}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Failed to parse response: {e}", "raw_response": ""}
|
||||
|
||||
try:
|
||||
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# Clean markdown backticks from response
|
||||
clean_content = content.strip()
|
||||
while clean_content.startswith("`") or clean_content.startswith("```"):
|
||||
clean_content = clean_content[1:].strip()
|
||||
while clean_content.endswith("`") or clean_content.endswith("```"):
|
||||
clean_content = clean_content[:-1].strip()
|
||||
|
||||
# Parse the JSON content
|
||||
return json.loads(clean_content)
|
||||
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
||||
return {"error": f"Failed to parse JSON: {e}", "raw_response": json.dumps(result)}
|
||||
145
src/json_extract/test_json_extract.py
Normal file
145
src/json_extract/test_json_extract.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/workspace/src")
|
||||
|
||||
from json_extract import structure_to_json
|
||||
|
||||
|
||||
class TestStructureToJson(unittest.TestCase):
|
||||
def setUp(self):
|
||||
os.environ.pop("LOCAL_LLM_API", None)
|
||||
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_successful_conversion(self, mock_urlopen):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"choices": [{"message": {"content": '{"name": "John", "age": 30}'}}]}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Nom: John, Age: 30"
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
|
||||
}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertEqual(result["name"], "John")
|
||||
self.assertEqual(result["age"], 30)
|
||||
self.assertNotIn("error", result)
|
||||
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_markdown_backtick_removal(self, mock_urlopen):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"choices": [{"message": {"content": '{"name": "John"}'}}]}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Nom: John"
|
||||
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertEqual(result["name"], "John")
|
||||
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_api_error_handling(self, mock_urlopen):
|
||||
import urllib.error
|
||||
|
||||
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
|
||||
|
||||
text = "Some text"
|
||||
schema = {"type": "object"}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("API request failed", result["error"])
|
||||
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_invalid_json_response(self, mock_urlopen):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"choices": [{"message": {"content": "This is not JSON"}}]}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Some text"
|
||||
schema = {"type": "object"}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertIn("error", result)
|
||||
self.assertIn("Failed to parse JSON", result["error"])
|
||||
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_empty_response(self, mock_urlopen):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps({"choices": [{}]}).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Some text"
|
||||
schema = {"type": "object"}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertIn("error", result)
|
||||
|
||||
@patch("json_extract.urllib.request.Request")
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_custom_api_url(self, mock_urlopen, mock_request):
|
||||
os.environ["LOCAL_LLM_API"] = "http://custom-api:8080/v1"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"choices": [{"message": {"content": '{"data": "test"}'}}]}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Test data"
|
||||
schema = {"type": "object", "properties": {"data": {"type": "string"}}}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertEqual(result["data"], "test")
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
self.assertEqual(call_args[0][0], "http://custom-api:8080/v1/chat/completions")
|
||||
|
||||
@patch("json_extract.urllib.request.Request")
|
||||
@patch("json_extract.urllib.request.urlopen")
|
||||
def test_default_api_url(self, mock_urlopen, mock_request):
|
||||
os.environ.pop("LOCAL_LLM_API", None)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"choices": [{"message": {"content": '{"data": "test"}'}}]}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
text = "Test data"
|
||||
schema = {"type": "object"}
|
||||
|
||||
result = structure_to_json(text, schema)
|
||||
|
||||
self.assertNotIn("error", result)
|
||||
mock_request.assert_called_once()
|
||||
call_args = mock_request.call_args
|
||||
self.assertEqual(
|
||||
call_args[0][0], "http://host.docker.internal:1248/v1/chat/completions"
|
||||
)
|
||||
|
||||
def test_empty_text_and_schema(self):
|
||||
result = structure_to_json("", {})
|
||||
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
134
src/jujutsu/__init__.py
Normal file
134
src/jujutsu/__init__.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import os
|
||||
import shlex
|
||||
from typing import Optional
|
||||
|
||||
from json_extract import structure_to_json
|
||||
from jujutsu.command import jj_log as jj_log_command
|
||||
from jujutsu.command import jj_diff
|
||||
from jujutsu.command import jj_new as jj_new_command
|
||||
|
||||
from .command import jj
|
||||
|
||||
|
||||
def jj_log(working_dir=None):
|
||||
"""
|
||||
Execute jj log command for a project and return structured data as JSON.
|
||||
|
||||
Args:
|
||||
project_path (str): The path of the project
|
||||
|
||||
Returns:
|
||||
dict: JSON structure with commit history
|
||||
"""
|
||||
# Execute jj log command with working_dir parameter
|
||||
log_output = jj_log_command(working_dir=working_dir)
|
||||
|
||||
# Define the schema for the JSON structure
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hash": {"type": "string"},
|
||||
"author": {"type": "string"},
|
||||
"date": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["hash", "author", "date", "message"],
|
||||
},
|
||||
}
|
||||
|
||||
# Convert the text output to JSON using the schema
|
||||
return structure_to_json(log_output, schema)
|
||||
|
||||
|
||||
def jj_commit_message(working_dir=None):
|
||||
"""
|
||||
Generate a commit message from the current diff using LOCAL_LLM_API.
|
||||
|
||||
Args:
|
||||
working_dir: The path of the project (optional)
|
||||
|
||||
Returns:
|
||||
str: Generated commit message in format "title\n\nmessage"
|
||||
"""
|
||||
diff_output = jj_diff(working_dir=working_dir)
|
||||
|
||||
prompt = """Analyse ce diff et réponds UNIQUEMENT avec un objet JSON valide contenant "title" (string) et "message" (string) pour le message de commit. Pas de texte avant ou après le JSON."""
|
||||
|
||||
content = f"{prompt}\n{diff_output}"
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "message"],
|
||||
}
|
||||
|
||||
result = structure_to_json(content, schema)
|
||||
|
||||
if "error" in result:
|
||||
return result["error"]
|
||||
|
||||
title = result.get("title", "")
|
||||
message = result.get("message", "")
|
||||
|
||||
return f"{title}\n\n{message}"
|
||||
|
||||
|
||||
def jj_auto_describe(working_dir=None):
|
||||
"""
|
||||
Automatically describe the current change using jj describe with AI-generated message.
|
||||
|
||||
Args:
|
||||
working_dir: The path of the project (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of jj describe command
|
||||
"""
|
||||
message = jj_commit_message(working_dir=working_dir)
|
||||
|
||||
args = ["-m", message]
|
||||
return jj("describe", args=args, working_dir=working_dir)
|
||||
|
||||
|
||||
def jj_new(
|
||||
revisions=None,
|
||||
message=None,
|
||||
no_edit=False,
|
||||
insert_after=None,
|
||||
insert_before=None,
|
||||
options=None,
|
||||
working_dir: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Create a new commit after documenting the current change with AI.
|
||||
|
||||
First runs jj_auto_describe to document the current working copy,
|
||||
then creates a new empty commit with jj new.
|
||||
|
||||
Args:
|
||||
revisions: List of parent revisions (default: [@])
|
||||
message: The change description to use
|
||||
no_edit: Do not edit the newly created change
|
||||
insert_after: Insert the new change after the given commit(s)
|
||||
insert_before: Insert the new change before the given commit(s)
|
||||
options: List of additional options
|
||||
working_dir: The path of the project (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of jj new command
|
||||
"""
|
||||
jj_auto_describe(working_dir=working_dir)
|
||||
|
||||
return jj_new_command(
|
||||
revisions=revisions,
|
||||
message=message,
|
||||
no_edit=no_edit,
|
||||
insert_after=insert_after,
|
||||
insert_before=insert_before,
|
||||
options=options,
|
||||
working_dir=working_dir,
|
||||
)
|
||||
181
src/jujutsu/command.py
Normal file
181
src/jujutsu/command.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def jj(
|
||||
subcommand: str,
|
||||
options: Optional[List[str]] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
template: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Lance une commande jj.
|
||||
|
||||
Args:
|
||||
subcommand: La sous-commande jj (ex: 'log', 'status', 'diff')
|
||||
options: Liste des options (ex: ['--limit', '5'])
|
||||
args: Liste des arguments additionnels
|
||||
template: Template jj à utiliser (ex: 'builtin_log_compact')
|
||||
working_dir: Répertoire de travail optionnel
|
||||
|
||||
Returns:
|
||||
str: sortie de la commande
|
||||
"""
|
||||
cmd = ["jj", subcommand]
|
||||
|
||||
# Ajoute le template spécifié
|
||||
if template:
|
||||
cmd.extend(["--template", template])
|
||||
|
||||
if options:
|
||||
cmd.extend(options)
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
# Si un répertoire de travail est spécifié, on le change temporairement
|
||||
if working_dir is not None:
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(working_dir)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
else:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Commande jj échouée: {' '.join(cmd)}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
return result.stdout
|
||||
|
||||
|
||||
def jj_log(options=None, args=None, template=None, working_dir: Optional[str] = None):
|
||||
"""
|
||||
Execute jj log command with optional arguments.
|
||||
|
||||
Args:
|
||||
options: List of options to pass to jj log (ex: ['--limit', '5'])
|
||||
args: List of additional arguments
|
||||
template: Template to use (ex: 'builtin_log_compact')
|
||||
working_dir: Working directory for the command (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of the jj log command
|
||||
"""
|
||||
return jj("log", options, args, template, working_dir=working_dir)
|
||||
|
||||
|
||||
def jj_clone(url, destination=None, options=None, working_dir: Optional[str] = None):
|
||||
"""
|
||||
Execute jj git clone command.
|
||||
|
||||
Args:
|
||||
url: The repository URL to clone
|
||||
destination: The destination directory (optional)
|
||||
options: List of additional options for jj git clone
|
||||
working_dir: Working directory for the command (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of the jj git clone command
|
||||
"""
|
||||
args = ["clone", url]
|
||||
if destination:
|
||||
args.append(destination)
|
||||
|
||||
if options:
|
||||
args.extend(options)
|
||||
|
||||
return jj("git", args=args, working_dir=working_dir)
|
||||
|
||||
|
||||
def jj_describe(
|
||||
message: Optional[str] = None, options=None, working_dir: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Execute jj describe command to set or view commit description.
|
||||
|
||||
Args:
|
||||
message: The description text to set (optional, reads current if not provided)
|
||||
options: List of additional options for jj describe
|
||||
working_dir: Working directory for the command (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of the jj describe command
|
||||
"""
|
||||
args = []
|
||||
if message:
|
||||
args.append(message)
|
||||
|
||||
if options:
|
||||
args.extend(options)
|
||||
|
||||
return jj("describe", args=args, working_dir=working_dir)
|
||||
|
||||
|
||||
def jj_diff(
|
||||
options=None, args=None, template=None, working_dir: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Execute jj diff command with optional arguments.
|
||||
|
||||
Args:
|
||||
options: List of options to pass to jj diff
|
||||
args: List of additional arguments
|
||||
template: Template to use
|
||||
working_dir: Working directory for the command (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of the jj diff command
|
||||
"""
|
||||
return jj("diff", options, args, template, working_dir=working_dir)
|
||||
|
||||
|
||||
def jj_new(
|
||||
revisions=None,
|
||||
message=None,
|
||||
no_edit=False,
|
||||
insert_after=None,
|
||||
insert_before=None,
|
||||
options=None,
|
||||
working_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Execute jj new command to create a new empty change.
|
||||
|
||||
Args:
|
||||
revisions: List of parent revisions (default: [@])
|
||||
message: The change description to use
|
||||
no_edit: Do not edit the newly created change
|
||||
insert_after: Insert the new change after the given commit(s)
|
||||
insert_before: Insert the new change before the given commit(s)
|
||||
options: List of additional options
|
||||
working_dir: Working directory for the command (optional)
|
||||
|
||||
Returns:
|
||||
str: Output of the jj new command
|
||||
"""
|
||||
args = []
|
||||
if revisions:
|
||||
args.extend(revisions)
|
||||
|
||||
if message:
|
||||
args.extend(["-m", message])
|
||||
|
||||
if no_edit:
|
||||
args.append("--no-edit")
|
||||
|
||||
if insert_after:
|
||||
args.extend(["--insert-after", insert_after])
|
||||
|
||||
if insert_before:
|
||||
args.extend(["--insert-before", insert_before])
|
||||
|
||||
if options:
|
||||
args.extend(options)
|
||||
|
||||
return jj("new", args=args, working_dir=working_dir)
|
||||
Reference in New Issue
Block a user