Skip to content

Commit 97c24fe

Browse files
committed
Add unit tests for LLM review response parser and enhance coverage for redundancy detection
1 parent fd01a72 commit 97c24fe

5 files changed

Lines changed: 1034 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,26 @@ pythonpath = ["src"]
6363
testpaths = ["tests"]
6464
addopts = "-ra --strict-config --strict-markers"
6565
xfail_strict = true
66+
markers = [
67+
"unit: fast isolated unit tests (no I/O, no network)",
68+
"integration: tests that touch the filesystem or external services",
69+
"smoke: high-level smoke tests for dashboard artifacts",
70+
"golden: snapshot/regression tests",
71+
]
6672

6773
[tool.coverage.run]
6874
branch = true
6975
source = ["portfolio_auditor"]
7076

7177
[tool.coverage.report]
72-
fail_under = 45
78+
# Raised from 45 → 65 now that scanners, reviewer, parser and deduplication
79+
# are covered. Target 80 once the LLM layer and CLI are exercised.
80+
fail_under = 65
7381
show_missing = true
7482
skip_covered = false
7583
precision = 2
7684
exclude_lines = [
7785
"pragma: no cover",
7886
"if __name__ == .__main__.:",
7987
"raise NotImplementedError",
80-
]
88+
]
Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,181 @@
11
from __future__ import annotations
22

3-
# Reserved for future structured LLM response parsing.
3+
"""
4+
reviewing/review_parser.py
5+
6+
Parses a structured LLM JSON response into a RepoReview, validating required
7+
fields and falling back gracefully on partial or malformed output.
8+
9+
Expected JSON shape
10+
-------------------
11+
{
12+
"executive_summary": "...",
13+
"recruiter_signal": "...",
14+
"strengths": [{"text": "...", "priority": "high|medium|low"}],
15+
"weaknesses": [{"text": "...", "priority": "..."}],
16+
"blockers": [{"text": "...", "priority": "high"}],
17+
"quick_wins": [{"text": "...", "priority": "..."}],
18+
"priority_actions": [{"text": "...", "priority": "..."}],
19+
"portfolio_decision": "FEATURE_NOW|KEEP_AND_IMPROVE|MERGE_OR_REPOSITION|ARCHIVE_PUBLIC|MAKE_PRIVATE",
20+
"portfolio_rationale": "..."
21+
}
22+
23+
All keys are optional — missing or null values are silently skipped.
24+
Unknown portfolio_decision values fall back to KEEP_AND_IMPROVE with a warning.
25+
"""
26+
27+
import json
28+
import logging
29+
import re
30+
from typing import Any
31+
32+
from portfolio_auditor.models.portfolio_decision import PortfolioDecision
33+
from portfolio_auditor.models.repo_review import RepoReview
34+
35+
logger = logging.getLogger(__name__)
36+
37+
_VALID_PRIORITIES = {"high", "medium", "low"}
38+
_VALID_DECISIONS = {d.value for d in PortfolioDecision}
39+
40+
41+
class LLMResponseParseError(ValueError):
42+
"""Raised when the raw LLM response cannot be parsed into valid JSON at all."""
43+
44+
45+
def parse_llm_review(
46+
raw_response: str,
47+
*,
48+
repo_name: str,
49+
repo_full_name: str,
50+
) -> RepoReview:
51+
"""
52+
Parse a raw LLM response string into a ``RepoReview``.
53+
54+
Parameters
55+
----------
56+
raw_response:
57+
Raw text from the LLM. May contain markdown fences (```json ... ```)
58+
that are stripped before JSON parsing.
59+
repo_name:
60+
Repo short name for the resulting ``RepoReview``.
61+
repo_full_name:
62+
Full ``owner/repo`` identifier for the resulting ``RepoReview``.
63+
64+
Returns
65+
-------
66+
RepoReview
67+
Partially or fully populated from the parsed JSON. Fields that are
68+
missing, null, or invalid are silently skipped.
69+
70+
Raises
71+
------
72+
LLMResponseParseError
73+
If the text cannot be decoded as JSON even after stripping fences.
74+
"""
75+
data = _extract_json(raw_response)
76+
77+
review = RepoReview(
78+
repo_name=repo_name,
79+
repo_full_name=repo_full_name,
80+
)
81+
82+
review.executive_summary = _extract_text(data, "executive_summary")
83+
review.recruiter_signal = _extract_text(data, "recruiter_signal")
84+
review.portfolio_rationale = _extract_text(data, "portfolio_rationale")
85+
86+
for item in _extract_bullets(data, "strengths"):
87+
review.add_strength(item["text"], priority=item.get("priority"))
88+
89+
for item in _extract_bullets(data, "weaknesses"):
90+
review.add_weakness(item["text"], priority=item.get("priority"))
91+
92+
for item in _extract_bullets(data, "blockers"):
93+
review.add_blocker(item["text"], priority=item.get("priority"))
94+
95+
for item in _extract_bullets(data, "quick_wins"):
96+
review.add_quick_win(item["text"], priority=item.get("priority"))
97+
98+
for item in _extract_bullets(data, "priority_actions"):
99+
review.add_priority_action(item["text"], priority=item.get("priority"))
100+
101+
review.portfolio_decision = _extract_decision(data)
102+
103+
return review
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# Internal helpers
108+
# ---------------------------------------------------------------------------
109+
110+
111+
def _extract_json(raw: str) -> dict[str, Any]:
112+
"""Strip markdown fences then JSON-decode. Raises LLMResponseParseError on failure."""
113+
# Remove ```json ... ``` or ``` ... ``` wrappers
114+
cleaned = re.sub(r"```(?:json)?\s*", "", raw).replace("```", "").strip()
115+
try:
116+
parsed = json.loads(cleaned)
117+
except json.JSONDecodeError as exc:
118+
raise LLMResponseParseError(
119+
f"LLM response is not valid JSON after stripping fences. "
120+
f"Original error: {exc}. "
121+
f"First 200 chars of cleaned text: {cleaned[:200]!r}"
122+
) from exc
123+
124+
if not isinstance(parsed, dict):
125+
raise LLMResponseParseError(
126+
f"Expected a JSON object at the top level, got {type(parsed).__name__}."
127+
)
128+
return parsed
129+
130+
131+
def _extract_text(data: dict[str, Any], key: str) -> str | None:
132+
value = data.get(key)
133+
if not isinstance(value, str):
134+
return None
135+
text = value.strip()
136+
return text or None
137+
138+
139+
def _extract_bullets(data: dict[str, Any], key: str) -> list[dict[str, Any]]:
140+
"""
141+
Extract a list of bullet dicts from the parsed JSON.
142+
143+
Accepts both:
144+
- list of dicts: [{"text": "...", "priority": "high"}, ...]
145+
- list of strings: ["...", "..."] (treated as text-only, no priority)
146+
"""
147+
raw = data.get(key)
148+
if not isinstance(raw, list):
149+
return []
150+
151+
bullets: list[dict[str, Any]] = []
152+
for item in raw:
153+
if isinstance(item, str):
154+
text = item.strip()
155+
if text:
156+
bullets.append({"text": text})
157+
elif isinstance(item, dict):
158+
text = str(item.get("text", "")).strip()
159+
if not text:
160+
continue
161+
priority_raw = str(item.get("priority", "")).strip().lower()
162+
priority = priority_raw if priority_raw in _VALID_PRIORITIES else None
163+
bullets.append({"text": text, "priority": priority})
164+
165+
return bullets
166+
167+
168+
def _extract_decision(data: dict[str, Any]) -> PortfolioDecision:
169+
raw = data.get("portfolio_decision")
170+
if not isinstance(raw, str):
171+
return PortfolioDecision.KEEP_AND_IMPROVE
172+
173+
candidate = raw.strip().upper()
174+
if candidate not in _VALID_DECISIONS:
175+
logger.warning(
176+
"Unknown portfolio_decision value %r from LLM — falling back to KEEP_AND_IMPROVE",
177+
raw,
178+
)
179+
return PortfolioDecision.KEEP_AND_IMPROVE
180+
181+
return PortfolioDecision(candidate)

0 commit comments

Comments
 (0)