project-brain-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- project_brain/__init__.py +1 -0
- project_brain/cli.py +644 -0
- project_brain/cli_help.py +52 -0
- project_brain/cli_ui.py +69 -0
- project_brain/config/__init__.py +0 -0
- project_brain/core/__init__.py +0 -0
- project_brain/core/analyzer.py +134 -0
- project_brain/core/config_loader.py +193 -0
- project_brain/core/differ.py +160 -0
- project_brain/core/doctor.py +29 -0
- project_brain/core/doctor_checks/analysis.py +110 -0
- project_brain/core/doctor_checks/environment.py +92 -0
- project_brain/core/doctor_checks/exports.py +70 -0
- project_brain/core/doctor_checks/llm.py +107 -0
- project_brain/core/doctor_checks/models.py +10 -0
- project_brain/core/doctor_checks/repository.py +102 -0
- project_brain/core/explainer.py +334 -0
- project_brain/core/explainer_file.py +136 -0
- project_brain/core/exporter.py +340 -0
- project_brain/core/logger.py +31 -0
- project_brain/core/results.py +163 -0
- project_brain/core/summarizer.py +108 -0
- project_brain/llm/__init__.py +0 -0
- project_brain/llm/provider.py +211 -0
- project_brain/storage/__init__.py +0 -0
- project_brain/storage/storage.py +12 -0
- project_brain_cli-1.0.0.dist-info/METADATA +1185 -0
- project_brain_cli-1.0.0.dist-info/RECORD +32 -0
- project_brain_cli-1.0.0.dist-info/WHEEL +5 -0
- project_brain_cli-1.0.0.dist-info/entry_points.txt +3 -0
- project_brain_cli-1.0.0.dist-info/licenses/LICENSE +11 -0
- project_brain_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from project_brain.core.doctor_checks.models import DoctorCheck
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_environment_checks(root: Path):
|
|
10
|
+
checks = []
|
|
11
|
+
|
|
12
|
+
# Python version
|
|
13
|
+
version = sys.version_info
|
|
14
|
+
|
|
15
|
+
if version.major >= 3 and version.minor >= 10:
|
|
16
|
+
checks.append(
|
|
17
|
+
DoctorCheck(
|
|
18
|
+
"Environment",
|
|
19
|
+
"Python",
|
|
20
|
+
"pass",
|
|
21
|
+
f"Python {version.major}.{version.minor}",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
else:
|
|
25
|
+
checks.append(
|
|
26
|
+
DoctorCheck(
|
|
27
|
+
"Environment",
|
|
28
|
+
"Python",
|
|
29
|
+
"fail",
|
|
30
|
+
"Python 3.10+ required",
|
|
31
|
+
"Upgrade Python to >=3.10",
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# OS
|
|
36
|
+
checks.append(
|
|
37
|
+
DoctorCheck(
|
|
38
|
+
"Environment",
|
|
39
|
+
"OS",
|
|
40
|
+
"info",
|
|
41
|
+
platform.platform(),
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Virtual env
|
|
46
|
+
venv_active = (
|
|
47
|
+
hasattr(sys, "real_prefix")
|
|
48
|
+
or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if venv_active:
|
|
52
|
+
checks.append(
|
|
53
|
+
DoctorCheck(
|
|
54
|
+
"Environment",
|
|
55
|
+
"Virtual Environment",
|
|
56
|
+
"pass",
|
|
57
|
+
"Virtual environment active",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
checks.append(
|
|
62
|
+
DoctorCheck(
|
|
63
|
+
"Environment",
|
|
64
|
+
"Virtual Environment",
|
|
65
|
+
"warn",
|
|
66
|
+
"No virtual environment detected",
|
|
67
|
+
"Create one:\npython -m venv env",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Git installed
|
|
72
|
+
if shutil.which("git"):
|
|
73
|
+
checks.append(
|
|
74
|
+
DoctorCheck(
|
|
75
|
+
"Environment",
|
|
76
|
+
"Git",
|
|
77
|
+
"pass",
|
|
78
|
+
"Git installed",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
checks.append(
|
|
83
|
+
DoctorCheck(
|
|
84
|
+
"Environment",
|
|
85
|
+
"Git",
|
|
86
|
+
"fail",
|
|
87
|
+
"Git not installed",
|
|
88
|
+
"Install Git from https://git-scm.com/",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return checks
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from project_brain.core.doctor_checks.models import DoctorCheck
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_export(path: Path, name: str):
|
|
7
|
+
if not path.exists():
|
|
8
|
+
return DoctorCheck(
|
|
9
|
+
"Exports",
|
|
10
|
+
name,
|
|
11
|
+
"warn",
|
|
12
|
+
"Export missing",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if path.stat().st_size == 0:
|
|
16
|
+
return DoctorCheck(
|
|
17
|
+
"Exports",
|
|
18
|
+
name,
|
|
19
|
+
"warn",
|
|
20
|
+
"Export empty",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return DoctorCheck(
|
|
24
|
+
"Exports",
|
|
25
|
+
name,
|
|
26
|
+
"pass",
|
|
27
|
+
"Export available",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_export_checks(root: Path):
|
|
32
|
+
checks = []
|
|
33
|
+
|
|
34
|
+
export_dir = root / ".brain" / "exports"
|
|
35
|
+
|
|
36
|
+
if not export_dir.exists():
|
|
37
|
+
checks.append(
|
|
38
|
+
DoctorCheck(
|
|
39
|
+
"Exports",
|
|
40
|
+
"Exports Directory",
|
|
41
|
+
"warn",
|
|
42
|
+
"Exports directory missing",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return checks
|
|
46
|
+
|
|
47
|
+
checks.append(
|
|
48
|
+
DoctorCheck(
|
|
49
|
+
"Exports",
|
|
50
|
+
"Exports Directory",
|
|
51
|
+
"pass",
|
|
52
|
+
"Exports directory exists",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
checks.append(
|
|
57
|
+
check_export(
|
|
58
|
+
export_dir / "full_code.txt",
|
|
59
|
+
"Full Code Export",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
checks.append(
|
|
64
|
+
check_export(
|
|
65
|
+
export_dir / "code_changes.txt",
|
|
66
|
+
"Code Changes Export",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return checks
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from project_brain.core.config_loader import load_config
|
|
6
|
+
from project_brain.core.doctor_checks.models import DoctorCheck
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_llm_checks(root: Path):
|
|
10
|
+
checks = []
|
|
11
|
+
|
|
12
|
+
config = load_config(root)
|
|
13
|
+
|
|
14
|
+
llm = config.get("llm", {})
|
|
15
|
+
|
|
16
|
+
provider = llm.get("provider", "none")
|
|
17
|
+
model = llm.get("model", "")
|
|
18
|
+
|
|
19
|
+
checks.append(
|
|
20
|
+
DoctorCheck(
|
|
21
|
+
"LLM",
|
|
22
|
+
"Provider",
|
|
23
|
+
"info",
|
|
24
|
+
provider,
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if provider == "none":
|
|
29
|
+
checks.append(
|
|
30
|
+
DoctorCheck(
|
|
31
|
+
"LLM",
|
|
32
|
+
"Status",
|
|
33
|
+
"info",
|
|
34
|
+
"LLM disabled",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return checks
|
|
38
|
+
|
|
39
|
+
if not model:
|
|
40
|
+
checks.append(
|
|
41
|
+
DoctorCheck(
|
|
42
|
+
"LLM",
|
|
43
|
+
"Model",
|
|
44
|
+
"fail",
|
|
45
|
+
"No model configured",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
checks.append(
|
|
50
|
+
DoctorCheck(
|
|
51
|
+
"LLM",
|
|
52
|
+
"Model",
|
|
53
|
+
"pass",
|
|
54
|
+
model,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if provider == "ollama":
|
|
59
|
+
try:
|
|
60
|
+
subprocess.run(
|
|
61
|
+
["ollama", "list"],
|
|
62
|
+
capture_output=True,
|
|
63
|
+
check=True,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
checks.append(
|
|
67
|
+
DoctorCheck(
|
|
68
|
+
"LLM",
|
|
69
|
+
"Ollama",
|
|
70
|
+
"pass",
|
|
71
|
+
"Ollama available",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
except Exception:
|
|
76
|
+
checks.append(
|
|
77
|
+
DoctorCheck(
|
|
78
|
+
"LLM",
|
|
79
|
+
"Ollama",
|
|
80
|
+
"fail",
|
|
81
|
+
"Ollama unavailable",
|
|
82
|
+
"Install Ollama",
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
elif provider == "openai":
|
|
87
|
+
if os.getenv("OPENAI_API_KEY"):
|
|
88
|
+
checks.append(
|
|
89
|
+
DoctorCheck(
|
|
90
|
+
"LLM",
|
|
91
|
+
"OPENAI_API_KEY",
|
|
92
|
+
"pass",
|
|
93
|
+
"API key configured",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
checks.append(
|
|
98
|
+
DoctorCheck(
|
|
99
|
+
"LLM",
|
|
100
|
+
"OPENAI_API_KEY",
|
|
101
|
+
"fail",
|
|
102
|
+
"Missing API key",
|
|
103
|
+
"Set OPENAI_API_KEY",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return checks
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from project_brain.core.differ import is_git_repo, run_git_command
|
|
4
|
+
from project_brain.core.doctor_checks.models import DoctorCheck
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_repo_size(root: Path):
|
|
8
|
+
total = 0
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
for path in root.rglob("*"):
|
|
12
|
+
if path.is_file():
|
|
13
|
+
total += path.stat().st_size
|
|
14
|
+
except Exception:
|
|
15
|
+
return "Unknown"
|
|
16
|
+
|
|
17
|
+
return f"{round(total / (1024 * 1024), 2)} MB"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_repository_checks(root: Path):
|
|
21
|
+
checks = []
|
|
22
|
+
|
|
23
|
+
if not is_git_repo(root):
|
|
24
|
+
checks.append(
|
|
25
|
+
DoctorCheck(
|
|
26
|
+
"Repository",
|
|
27
|
+
"Git Repository",
|
|
28
|
+
"warn",
|
|
29
|
+
"Not a git repository",
|
|
30
|
+
"Run:\ngit init",
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
return checks
|
|
34
|
+
|
|
35
|
+
checks.append(
|
|
36
|
+
DoctorCheck(
|
|
37
|
+
"Repository",
|
|
38
|
+
"Git Repository",
|
|
39
|
+
"pass",
|
|
40
|
+
"Git repository detected",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
branch = run_git_command(
|
|
45
|
+
["branch", "--show-current"],
|
|
46
|
+
root,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if branch:
|
|
50
|
+
checks.append(
|
|
51
|
+
DoctorCheck(
|
|
52
|
+
"Repository",
|
|
53
|
+
"Branch",
|
|
54
|
+
"pass",
|
|
55
|
+
branch,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
checks.append(
|
|
60
|
+
DoctorCheck(
|
|
61
|
+
"Repository",
|
|
62
|
+
"Branch",
|
|
63
|
+
"warn",
|
|
64
|
+
"Detached HEAD state",
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
commit = run_git_command(
|
|
69
|
+
["rev-parse", "--short", "HEAD"],
|
|
70
|
+
root,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if commit:
|
|
74
|
+
checks.append(
|
|
75
|
+
DoctorCheck(
|
|
76
|
+
"Repository",
|
|
77
|
+
"Latest Commit",
|
|
78
|
+
"pass",
|
|
79
|
+
commit,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
checks.append(
|
|
84
|
+
DoctorCheck(
|
|
85
|
+
"Repository",
|
|
86
|
+
"Latest Commit",
|
|
87
|
+
"warn",
|
|
88
|
+
"No commits found",
|
|
89
|
+
"Create initial commit",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
checks.append(
|
|
94
|
+
DoctorCheck(
|
|
95
|
+
"Repository",
|
|
96
|
+
"Repository Size",
|
|
97
|
+
"info",
|
|
98
|
+
get_repo_size(root),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return checks
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from project_brain.core.config_loader import load_config
|
|
8
|
+
from project_brain.core.differ import (compute_diff, extract_functions,
|
|
9
|
+
get_file_from_ref)
|
|
10
|
+
from project_brain.core.logger import log_error, log_warning
|
|
11
|
+
from project_brain.llm.provider import call_llm
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hash_pair(old: str, new: str, fn: str) -> str:
|
|
15
|
+
return hashlib.sha256((old + new + fn).encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_cache(cache_dir: Path, key: str):
|
|
19
|
+
path = cache_dir / f"{key}.json"
|
|
20
|
+
if path.exists():
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(path.read_text())
|
|
23
|
+
except Exception as e:
|
|
24
|
+
log_warning(f"⚠️ Corrupted cache ignored: {path}")
|
|
25
|
+
return None
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cleanup_cache(cache_dir: Path, max_files=1000):
|
|
30
|
+
files = sorted(cache_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)
|
|
31
|
+
|
|
32
|
+
if len(files) > max_files:
|
|
33
|
+
for f in files[: len(files) - max_files]:
|
|
34
|
+
f.unlink()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_valid_cache(data: dict) -> bool:
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
if "fast" not in data or "detailed" not in data:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
for section in ["fast", "detailed"]:
|
|
45
|
+
part = data.get(section)
|
|
46
|
+
if not isinstance(part, dict):
|
|
47
|
+
return False
|
|
48
|
+
if not all(k in part for k in ["change", "impact", "risk"]):
|
|
49
|
+
return False
|
|
50
|
+
if part["risk"] not in ["low", "medium", "high"]:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_cache(cache_dir: Path, key: str, data: dict):
|
|
57
|
+
if not is_valid_cache(data):
|
|
58
|
+
log_warning(f"Invalid cache skipped: {key}")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path = cache_dir / f"{key}.json"
|
|
63
|
+
path.write_text(json.dumps(data, indent=2))
|
|
64
|
+
cleanup_cache(cache_dir)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_prompt(old_code: str, new_code: str, fn: str) -> str:
|
|
68
|
+
return f"""
|
|
69
|
+
You are a senior software engineer performing a code review.
|
|
70
|
+
|
|
71
|
+
Analyze the function change and return STRICT JSON ONLY.
|
|
72
|
+
|
|
73
|
+
DO NOT use markdown.
|
|
74
|
+
DO NOT add text outside JSON.
|
|
75
|
+
|
|
76
|
+
Return EXACTLY:
|
|
77
|
+
|
|
78
|
+
{{
|
|
79
|
+
"fast": {{
|
|
80
|
+
"change": "(1-2 lines) short line summary",
|
|
81
|
+
"impact": "(1-2 lines) short line impact",
|
|
82
|
+
"risk": "low | medium | high"
|
|
83
|
+
}},
|
|
84
|
+
"detailed": {{
|
|
85
|
+
"change": "Explain what changed at logic level (4-6 lines)",
|
|
86
|
+
"impact": "Explain system-level impact and behavior changes (4-6 lines)",
|
|
87
|
+
"risk": "Explain actual risk reasoning + severity (2-3 lines, end with low|medium|high)"
|
|
88
|
+
}}
|
|
89
|
+
}}
|
|
90
|
+
|
|
91
|
+
Rules:
|
|
92
|
+
- fast = minimal summary only
|
|
93
|
+
- detailed MUST include reasoning, not just description
|
|
94
|
+
- DO NOT repeat fast output in detailed
|
|
95
|
+
- risk must still end with one of: low, medium, high
|
|
96
|
+
|
|
97
|
+
Function: {fn}
|
|
98
|
+
|
|
99
|
+
Old Code:
|
|
100
|
+
{old_code}
|
|
101
|
+
|
|
102
|
+
New Code:
|
|
103
|
+
{new_code}
|
|
104
|
+
""".strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def safe_extract(source: str):
|
|
108
|
+
try:
|
|
109
|
+
return extract_functions(source)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
log_error(f"Function failed: {str(e)}")
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def explain_diff(from_ref: str, to_ref: str, root: Path) -> list[dict] | None:
|
|
116
|
+
config = load_config(root)
|
|
117
|
+
llm_cfg = config.get("llm", {})
|
|
118
|
+
|
|
119
|
+
provider = llm_cfg.get("provider", "none")
|
|
120
|
+
model = llm_cfg.get("model", "")
|
|
121
|
+
api_key = ""
|
|
122
|
+
|
|
123
|
+
diff = compute_diff(from_ref, to_ref, root)
|
|
124
|
+
if not diff:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
results = []
|
|
128
|
+
cache_dir = root / ".brain" / "cache"
|
|
129
|
+
|
|
130
|
+
for fd in diff["function_diffs"]:
|
|
131
|
+
file = fd["file"]
|
|
132
|
+
|
|
133
|
+
old_src = get_file_from_ref(from_ref, file, root) or ""
|
|
134
|
+
new_src = get_file_from_ref(to_ref, file, root) or ""
|
|
135
|
+
|
|
136
|
+
old_funcs = safe_extract(old_src)
|
|
137
|
+
new_funcs = safe_extract(new_src)
|
|
138
|
+
|
|
139
|
+
changed_funcs = list(
|
|
140
|
+
set(fd["added"]) | set(fd["removed"]) | set(fd["modified"])
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not changed_funcs:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
for fn in changed_funcs:
|
|
147
|
+
old_code = old_funcs.get(fn, "")
|
|
148
|
+
new_code = new_funcs.get(fn, "")
|
|
149
|
+
|
|
150
|
+
key = hash_pair(old_code, new_code, fn)
|
|
151
|
+
cached = load_cache(cache_dir, key)
|
|
152
|
+
|
|
153
|
+
if cached and is_valid_cache(cached):
|
|
154
|
+
data = select_output(cached, config)
|
|
155
|
+
results.append({"file": file, "function": fn, **data})
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# No LLM configured
|
|
159
|
+
if provider == "none":
|
|
160
|
+
data = {
|
|
161
|
+
"change": f"Function '{fn}' has code-level changes",
|
|
162
|
+
"impact": "Behavior may differ depending on logic changes",
|
|
163
|
+
"risk": "medium",
|
|
164
|
+
}
|
|
165
|
+
results.append({"file": file, "function": fn, **data})
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
prompt = build_prompt(old_code, new_code, fn)
|
|
169
|
+
response = call_llm(provider, model, prompt, api_key)
|
|
170
|
+
|
|
171
|
+
if response["error"]:
|
|
172
|
+
log_error(f"LLM error: {response['error']}")
|
|
173
|
+
parsed = None
|
|
174
|
+
else:
|
|
175
|
+
parsed = normalize_response(response["output"], provider, model, api_key)
|
|
176
|
+
|
|
177
|
+
# retry if parsing failed
|
|
178
|
+
if not parsed:
|
|
179
|
+
fix_prompt = f"""
|
|
180
|
+
Convert this into the required JSON structure:
|
|
181
|
+
|
|
182
|
+
{response["output"]}
|
|
183
|
+
|
|
184
|
+
Return ONLY:
|
|
185
|
+
{{
|
|
186
|
+
"fast": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }},
|
|
187
|
+
"detailed": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }}
|
|
188
|
+
}}
|
|
189
|
+
"""
|
|
190
|
+
response = call_llm(provider, model, fix_prompt, api_key)
|
|
191
|
+
if response["error"]:
|
|
192
|
+
parsed = None
|
|
193
|
+
else:
|
|
194
|
+
parsed = parse_llm_json(response["output"])
|
|
195
|
+
|
|
196
|
+
if parsed:
|
|
197
|
+
save_cache(cache_dir, key, parsed)
|
|
198
|
+
data = select_output(parsed, config)
|
|
199
|
+
else:
|
|
200
|
+
data = {
|
|
201
|
+
"change": "Failed to generate structured output",
|
|
202
|
+
"impact": "Unknown",
|
|
203
|
+
"risk": "high"
|
|
204
|
+
}
|
|
205
|
+
results.append({"file": file, "function": fn, **data})
|
|
206
|
+
explain_cfg = config.get("explain", {})
|
|
207
|
+
include_risks = explain_cfg.get("include_risks", True)
|
|
208
|
+
|
|
209
|
+
if not include_risks:
|
|
210
|
+
for result in results:
|
|
211
|
+
result["risk"] = ""
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_llm_json(response: str):
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(response)
|
|
218
|
+
|
|
219
|
+
if not all(k in data for k in ["fast", "detailed"]):
|
|
220
|
+
raise ValueError("Missing structure")
|
|
221
|
+
|
|
222
|
+
return data
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
log_error(f"Function failed: {str(e)}")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def normalize_response(response_text: str, provider, model, api_key):
|
|
230
|
+
# Step 1: direct parse
|
|
231
|
+
parsed = parse_llm_json(response_text)
|
|
232
|
+
if parsed and is_valid_cache(parsed):
|
|
233
|
+
return parsed
|
|
234
|
+
|
|
235
|
+
# Step 2: try extracting legacy text
|
|
236
|
+
extracted = extract_from_old_text(response_text)
|
|
237
|
+
if extracted.get("change"):
|
|
238
|
+
structured = {
|
|
239
|
+
"fast": extracted,
|
|
240
|
+
"detailed": extracted
|
|
241
|
+
}
|
|
242
|
+
if is_valid_cache(structured):
|
|
243
|
+
return structured
|
|
244
|
+
|
|
245
|
+
# Step 3: LLM fix (last resort)
|
|
246
|
+
fix_prompt = f"""
|
|
247
|
+
Convert into STRICT JSON:
|
|
248
|
+
|
|
249
|
+
{response_text}
|
|
250
|
+
|
|
251
|
+
Return ONLY:
|
|
252
|
+
{{
|
|
253
|
+
"fast": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }},
|
|
254
|
+
"detailed": {{ "change": "...", "impact": "...", "risk": "low|medium|high" }}
|
|
255
|
+
}}
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
response = call_llm(provider, model, fix_prompt, api_key)
|
|
259
|
+
|
|
260
|
+
if response["error"]:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
parsed = parse_llm_json(response["output"])
|
|
264
|
+
if parsed and is_valid_cache(parsed):
|
|
265
|
+
return parsed
|
|
266
|
+
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def extract_from_old_text(text: str):
|
|
271
|
+
sections = {"change": "", "impact": "", "risk": ""}
|
|
272
|
+
|
|
273
|
+
# Normalize
|
|
274
|
+
text = text.replace("\r", "").strip()
|
|
275
|
+
|
|
276
|
+
# Patterns
|
|
277
|
+
change_match = re.search(
|
|
278
|
+
r"(?:What changed|### 1\..*?changed)(.*?)(?:###|$)", text, re.S | re.I
|
|
279
|
+
)
|
|
280
|
+
impact_match = re.search(
|
|
281
|
+
r"(?:Why it matters|Impact|### 2\..*?|### 3\..*?)(.*?)(?:###|$)",
|
|
282
|
+
text,
|
|
283
|
+
re.S | re.I,
|
|
284
|
+
)
|
|
285
|
+
risk_match = re.search(r"(?:Risk|### 4\..*?risk)(.*?)(?:###|$)", text, re.S | re.I)
|
|
286
|
+
|
|
287
|
+
if change_match:
|
|
288
|
+
sections["change"] = change_match.group(1).strip()
|
|
289
|
+
|
|
290
|
+
if impact_match:
|
|
291
|
+
sections["impact"] = impact_match.group(1).strip()
|
|
292
|
+
|
|
293
|
+
if risk_match:
|
|
294
|
+
sections["risk"] = risk_match.group(1).strip()
|
|
295
|
+
|
|
296
|
+
# Normalize risk
|
|
297
|
+
r = sections["risk"].lower()
|
|
298
|
+
if "high" in r:
|
|
299
|
+
sections["risk"] = "high"
|
|
300
|
+
elif "medium" in r:
|
|
301
|
+
sections["risk"] = "medium"
|
|
302
|
+
else:
|
|
303
|
+
sections["risk"] = "low"
|
|
304
|
+
|
|
305
|
+
return sections
|
|
306
|
+
|
|
307
|
+
def select_output(parsed: dict, config: dict):
|
|
308
|
+
explain_cfg = config.get("explain", {})
|
|
309
|
+
|
|
310
|
+
level = explain_cfg.get("level", "detailed")
|
|
311
|
+
include_risks = explain_cfg.get("include_risks", True)
|
|
312
|
+
|
|
313
|
+
selected = parsed.get(level, {})
|
|
314
|
+
|
|
315
|
+
# ensure fields exist
|
|
316
|
+
change = selected.get("change", "")
|
|
317
|
+
impact = selected.get("impact", "")
|
|
318
|
+
risk = selected.get("risk", "medium")
|
|
319
|
+
|
|
320
|
+
if risk not in ["low", "medium", "high"]:
|
|
321
|
+
risk = "medium"
|
|
322
|
+
|
|
323
|
+
if not include_risks:
|
|
324
|
+
return {
|
|
325
|
+
"change": change,
|
|
326
|
+
"impact": impact,
|
|
327
|
+
"risk": ""
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"change": change,
|
|
332
|
+
"impact": impact,
|
|
333
|
+
"risk": risk
|
|
334
|
+
}
|