Skip to content

Commit c033320

Browse files
committed
Fix ruff format & CI test
1 parent ac51af2 commit c033320

22 files changed

Lines changed: 1080 additions & 707 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ jobs:
5959
# Exit code 5 = "no tests collected" — treat as pass until tests are added.
6060
# Real failures (exit code 1) still fail CI.
6161
run: |
62+
set +e
6263
pytest tests/ --tb=short -q
6364
STATUS=$?
65+
set -e
6466
[ $STATUS -eq 5 ] && exit 0 || exit $STATUS
6567
shell: bash
6668

src/agents/acquire_agent.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@
1919
# Diagnostic Test Accuracy (DTA) filter for Diagnosis questions.
2020
# Targets sensitivity, specificity, ROC, likelihood ratio, and QUADAS studies.
2121
_DTA_FILTER = (
22-
"(sensitivity[tiab] OR specificity[tiab] OR \"diagnostic accuracy\"[tiab] "
23-
"OR \"likelihood ratio\"[tiab] OR \"ROC curve\"[tiab] OR \"area under the curve\"[tiab] "
24-
"OR QUADAS[tiab] OR \"diagnostic test\"[tiab] OR \"predictive value\"[tiab] "
22+
'(sensitivity[tiab] OR specificity[tiab] OR "diagnostic accuracy"[tiab] '
23+
'OR "likelihood ratio"[tiab] OR "ROC curve"[tiab] OR "area under the curve"[tiab] '
24+
'OR QUADAS[tiab] OR "diagnostic test"[tiab] OR "predictive value"[tiab] '
2525
"OR systematic[sb])"
2626
)
2727

2828
# Observational filter for Prognosis and Harm questions.
2929
# Allows cohort studies; keeps systematic reviews; drops RCT bias.
3030
_OBSERVATIONAL_FILTER = (
3131
"(cohort[tiab] OR prospective[tiab] OR retrospective[tiab] "
32-
"OR \"follow-up\"[tiab] OR prognosis[tiab] OR survival[tiab] "
33-
"OR \"risk factor\"[tiab] OR incidence[tiab] OR mortality[tiab] "
32+
'OR "follow-up"[tiab] OR prognosis[tiab] OR survival[tiab] '
33+
'OR "risk factor"[tiab] OR incidence[tiab] OR mortality[tiab] '
3434
"OR systematic[sb])"
3535
)
3636

@@ -86,10 +86,10 @@ def _extract_query(self, content: str) -> str:
8686
remainder = content[query_start:].strip()
8787
if "```" in remainder:
8888
cb_start = remainder.find("```")
89-
after_backticks = remainder[cb_start + 3:]
89+
after_backticks = remainder[cb_start + 3 :]
9090
newline = after_backticks.find("\n")
9191
if newline >= 0:
92-
after_backticks = after_backticks[newline + 1:]
92+
after_backticks = after_backticks[newline + 1 :]
9393
cb_end = after_backticks.find("```")
9494
if cb_end >= 0:
9595
return after_backticks[:cb_end].strip()
@@ -114,9 +114,15 @@ def _infer_study_type(self, evidence: Evidence) -> str:
114114
text = f"{evidence.title} {evidence.abstract or ''}".lower()
115115
if "systematic review" in text or "meta-analysis" in text:
116116
return "Systematic Review"
117-
if ("randomized controlled trial" in text or "randomised controlled trial" in text or
118-
"randomized clinical trial" in text or "randomised clinical trial" in text or
119-
"rct" in text or " randomized " in text or " randomised " in text):
117+
if (
118+
"randomized controlled trial" in text
119+
or "randomised controlled trial" in text
120+
or "randomized clinical trial" in text
121+
or "randomised clinical trial" in text
122+
or "rct" in text
123+
or " randomized " in text
124+
or " randomised " in text
125+
):
120126
return "RCT"
121127
if "cohort study" in text or "cohort" in text:
122128
return "Cohort Study"
@@ -153,7 +159,9 @@ def _listwise_rank(
153159
lines = []
154160
for i, e in enumerate(candidates):
155161
study_hint = f"[{e.study_type}] " if e.study_type else ""
156-
abstract_preview = e.key_sentences if e.key_sentences else (e.abstract or "")[:150]
162+
abstract_preview = (
163+
e.key_sentences if e.key_sentences else (e.abstract or "")[:150]
164+
)
157165
lines.append(
158166
f"[{i + 1}] {study_hint}{e.title}\n"
159167
f" Abstract: {abstract_preview}"
@@ -171,7 +179,9 @@ def _listwise_rank(
171179
)
172180

173181
response = self.llm.invoke(prompt)
174-
print(f"[DEBUG] Listwise ranking response (first 300 chars): {response.content[:300]}")
182+
print(
183+
f"[DEBUG] Listwise ranking response (first 300 chars): {response.content[:300]}"
184+
)
175185

176186
# Parse ranked IDs, validate, deduplicate
177187
try:
@@ -239,18 +249,24 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
239249
try:
240250
t0 = time.time()
241251
if self._use_local_db(question_type):
242-
print(f"[DEBUG] question_type={question_type}, routing to local obstetrics DB")
252+
print(
253+
f"[DEBUG] question_type={question_type}, routing to local obstetrics DB"
254+
)
243255
raw_results = search_local(query=base_query, top_k=20)
244256
search_query_used = base_query
245257
print(f"[DEBUG] Local DB returned {len(raw_results)} articles")
246258
print(f"[TIMING] Local DB search: {time.time()-t0:.1f}s")
247259
else:
248260
filtered_query = self._apply_search_filter(base_query, question_type)
249-
print(f"[DEBUG] question_type={question_type}, filtered query: {filtered_query}")
261+
print(
262+
f"[DEBUG] question_type={question_type}, filtered query: {filtered_query}"
263+
)
250264
raw_results = search_pubmed(query=filtered_query, max_results=20)
251265
print(f"[DEBUG] PubMed (filtered) returned {len(raw_results)} articles")
252266
if len(raw_results) == 0:
253-
print("[DEBUG] Filtered query returned 0 results — falling back to base query")
267+
print(
268+
"[DEBUG] Filtered query returned 0 results — falling back to base query"
269+
)
254270
raw_results = search_pubmed(query=base_query, max_results=20)
255271
print(f"[DEBUG] PubMed (base) returned {len(raw_results)} articles")
256272
search_query_used = base_query

src/agents/apply_agent.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from src.agents.base import BaseAgent, robust_parse_json
44
from src.state.schema import WorkflowState, Recommendation
55

6+
67
class ApplyAgent(BaseAgent):
78
"""Agent for generating clinical recommendations"""
89

@@ -12,8 +13,10 @@ def __init__(self, llm, tools: List[Any] = None):
1213

1314
def _load_prompt(self) -> str:
1415
"""Load prompt template from file"""
15-
prompt_path = Path(__file__).parent.parent / "config" / "prompts" / "apply_agent.txt"
16-
with open(prompt_path, 'r', encoding='utf-8') as f:
16+
prompt_path = (
17+
Path(__file__).parent.parent / "config" / "prompts" / "apply_agent.txt"
18+
)
19+
with open(prompt_path, "r", encoding="utf-8") as f:
1720
return f.read()
1821

1922
def _parse_json(self, content: str) -> dict:
@@ -32,16 +35,18 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
3235
if state.get("backtrack_reason"):
3336
backtrack_context = f"Previous attempt failed: {state['backtrack_reason']}\nPlease address these issues in your recommendation."
3437

35-
evidence_summary = "\n\n".join([
36-
f"Evidence {i+1}:\nTitle: {e.title}\nQuality: {e.grade_level}\nSource: {e.source}"
37-
for i, e in enumerate(appraisal.evidence)
38-
])
38+
evidence_summary = "\n\n".join(
39+
[
40+
f"Evidence {i+1}:\nTitle: {e.title}\nQuality: {e.grade_level}\nSource: {e.source}"
41+
for i, e in enumerate(appraisal.evidence)
42+
]
43+
)
3944

4045
prompt = self.prompt_template.format(
4146
question=question,
4247
evidence_summary=evidence_summary,
4348
appraisal_summary=appraisal.summary,
44-
backtrack_context=backtrack_context
49+
backtrack_context=backtrack_context,
4550
)
4651

4752
response = self.llm.invoke(prompt)
@@ -50,7 +55,9 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
5055
except ValueError:
5156
# LLM may output reasoning only without JSON (response truncated or forgot JSON).
5257
# Retry once with a short follow-up prompt asking only for the JSON block.
53-
print("[WARN] Apply agent: JSON parse failed on first response, retrying for JSON only.")
58+
print(
59+
"[WARN] Apply agent: JSON parse failed on first response, retrying for JSON only."
60+
)
5461
retry_prompt = (
5562
"Your previous response did not include a valid JSON block. "
5663
"Please output ONLY the following JSON (no reasoning, no extra text):\n\n"
@@ -91,7 +98,7 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
9198
strength=strength,
9299
rationale=rec_dict["rationale"],
93100
caveats=rec_dict.get("caveats", []),
94-
evidence_quality=evidence_quality
101+
evidence_quality=evidence_quality,
95102
)
96103

97104
return {"recommendation": recommendation}

src/agents/appraise_agent.py

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@
1212
# Initial GRADE points by study type (4=High, 3=Moderate, 2=Low, 1=Very Low)
1313
_INITIAL_POINTS: Dict[str, int] = {
1414
"RCT": 4,
15-
"SYSTEMATIC_REVIEW": 4, # Starts High (synthesizes RCTs or best available evidence)
16-
"META_ANALYSIS": 4, # Starts High
17-
"NMA": 4, # Network meta-analysis: starts High
15+
"SYSTEMATIC_REVIEW": 4, # Starts High (synthesizes RCTs or best available evidence)
16+
"META_ANALYSIS": 4, # Starts High
17+
"NMA": 4, # Network meta-analysis: starts High
1818
"COHORT": 2,
1919
"CASE_CONTROL": 2,
20-
"CROSS_SECTIONAL": 2, # Observational: starts Low
21-
"NARRATIVE_REVIEW": 1, # Expert synthesis without systematic search: Very Low
20+
"CROSS_SECTIONAL": 2, # Observational: starts Low
21+
"NARRATIVE_REVIEW": 1, # Expert synthesis without systematic search: Very Low
2222
"CASE_REPORT": 1,
23-
"GUIDELINE": 3, # Typically based on SR: starts Moderate
24-
"EXPERT_OPINION": 1, # No systematic search: Very Low
23+
"GUIDELINE": 3, # Typically based on SR: starts Moderate
24+
"EXPERT_OPINION": 1, # No systematic search: Very Low
2525
}
2626

2727
# Mapping from GRADE codes to human-readable study type labels
@@ -123,7 +123,9 @@ def __init__(self, llm, tools: List[Any] = None):
123123
self.prompt_template = self._load_prompt()
124124

125125
def _load_prompt(self) -> str:
126-
prompt_path = Path(__file__).parent.parent / "config" / "prompts" / "appraise_agent.txt"
126+
prompt_path = (
127+
Path(__file__).parent.parent / "config" / "prompts" / "appraise_agent.txt"
128+
)
127129
with open(prompt_path, "r", encoding="utf-8") as f:
128130
return f.read()
129131

@@ -137,7 +139,11 @@ def _format_evidence_list(self, evidence_list) -> str:
137139
for i, e in enumerate(evidence_list):
138140
abstract_preview = (getattr(e, "abstract", "") or "")[:200]
139141
study_type_hint = getattr(e, "study_type", "") or ""
140-
hint_str = f"\nSource DB study_type hint: {study_type_hint}" if study_type_hint else ""
142+
hint_str = (
143+
f"\nSource DB study_type hint: {study_type_hint}"
144+
if study_type_hint
145+
else ""
146+
)
141147
parts.append(
142148
f"Evidence {i + 1}:\n"
143149
f"Title: {e.title}\n"
@@ -157,7 +163,9 @@ def _appraise_batch(self, evidence_subset: list, backtrack_context: str) -> dict
157163
response = self.llm.invoke(prompt)
158164
return self._parse_json(response.content)
159165

160-
def _merge_appraisal_dicts(self, results: List[dict], batch_sizes: List[int]) -> dict:
166+
def _merge_appraisal_dicts(
167+
self, results: List[dict], batch_sizes: List[int]
168+
) -> dict:
161169
"""
162170
Merge multiple batch appraisal results into a single appraisal dict.
163171
@@ -175,8 +183,12 @@ def _merge_appraisal_dicts(self, results: List[dict], batch_sizes: List[int]) ->
175183
global_id += 1
176184

177185
# Conflict: pessimistic — YES wins
178-
merged_conflict = {"conflicts_exist": "NO", "conflict_type": "NA",
179-
"conflict_severity": "NA", "conflict_description": None}
186+
merged_conflict = {
187+
"conflicts_exist": "NO",
188+
"conflict_type": "NA",
189+
"conflict_severity": "NA",
190+
"conflict_description": None,
191+
}
180192
for result in results:
181193
c = result.get("conflict_assessment", {})
182194
if c.get("conflicts_exist") == "YES":
@@ -194,7 +206,9 @@ def _merge_appraisal_dicts(self, results: List[dict], batch_sizes: List[int]) ->
194206
best_numerical = n
195207

196208
# Summary: join all
197-
summaries = [r.get("evidence_summary", "") for r in results if r.get("evidence_summary")]
209+
summaries = [
210+
r.get("evidence_summary", "") for r in results if r.get("evidence_summary")
211+
]
198212
merged_summary = " | ".join(summaries)
199213

200214
return {
@@ -235,8 +249,12 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
235249
for batch in batches
236250
]
237251
batch_results = [f.result() for f in futures]
238-
print(f"[TIMING] Appraise parallel batches ({len(batches)}×{_BATCH_SIZE}): {time.time()-t0:.1f}s")
239-
appraisal_dict = self._merge_appraisal_dicts(batch_results, [len(b) for b in batches])
252+
print(
253+
f"[TIMING] Appraise parallel batches ({len(batches)}×{_BATCH_SIZE}): {time.time()-t0:.1f}s"
254+
)
255+
appraisal_dict = self._merge_appraisal_dicts(
256+
batch_results, [len(b) for b in batches]
257+
)
240258
print(f"[PARALLEL-APPRAISE] {len(batches)} batches completed in parallel.")
241259
else:
242260
# Single batch (≤5 articles): no need for parallel overhead
@@ -260,27 +278,33 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
260278
graded_evidence.append(evidence)
261279

262280
# Build rich rationale record for downstream consumers (including Judge)
263-
initial_grade = _POINTS_TO_GRADE.get(_INITIAL_POINTS.get(study_type, 1), "Very Low")
264-
grade_rationales.append({
265-
"evidence_id": i + 1,
266-
"title": evidence.title,
267-
"study_type": study_type,
268-
"initial_grade": initial_grade,
269-
"risk_of_bias": appraisal.get("risk_of_bias", "NOT_SERIOUS"),
270-
"inconsistency": appraisal.get("inconsistency", "NA"),
271-
"indirectness": appraisal.get("indirectness", "NOT_SERIOUS"),
272-
"imprecision": appraisal.get("imprecision", "NOT_SERIOUS"),
273-
"publication_bias": appraisal.get("publication_bias", "UNDETECTED"),
274-
"large_effect": appraisal.get("large_effect", "NA"),
275-
"dose_response": appraisal.get("dose_response", "NA"),
276-
"computed_grade": computed_grade,
277-
"rationale": appraisal.get("rationale", ""),
278-
})
281+
initial_grade = _POINTS_TO_GRADE.get(
282+
_INITIAL_POINTS.get(study_type, 1), "Very Low"
283+
)
284+
grade_rationales.append(
285+
{
286+
"evidence_id": i + 1,
287+
"title": evidence.title,
288+
"study_type": study_type,
289+
"initial_grade": initial_grade,
290+
"risk_of_bias": appraisal.get("risk_of_bias", "NOT_SERIOUS"),
291+
"inconsistency": appraisal.get("inconsistency", "NA"),
292+
"indirectness": appraisal.get("indirectness", "NOT_SERIOUS"),
293+
"imprecision": appraisal.get("imprecision", "NOT_SERIOUS"),
294+
"publication_bias": appraisal.get("publication_bias", "UNDETECTED"),
295+
"large_effect": appraisal.get("large_effect", "NA"),
296+
"dose_response": appraisal.get("dose_response", "NA"),
297+
"computed_grade": computed_grade,
298+
"rationale": appraisal.get("rationale", ""),
299+
}
300+
)
279301

280302
# --- Conflict assessment ---
281303
conflict = appraisal_dict.get("conflict_assessment", {})
282304
has_conflict = conflict.get("conflicts_exist") == "YES"
283-
conflict_description = conflict.get("conflict_description") if has_conflict else None
305+
conflict_description = (
306+
conflict.get("conflict_description") if has_conflict else None
307+
)
284308

285309
# --- Numerical assessment ---
286310
numerical = appraisal_dict.get("numerical_assessment", {})
@@ -304,5 +328,6 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
304328
"note": numerical.get("note", ""),
305329
},
306330
# True only when there is a MAJOR conflict (influences scheduling)
307-
"bias_inconsistency": has_conflict and conflict.get("conflict_severity") == "MAJOR",
331+
"bias_inconsistency": has_conflict
332+
and conflict.get("conflict_severity") == "MAJOR",
308333
}

src/agents/ask_agent.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from src.agents.base import BaseAgent, robust_parse_json
44
from src.state.schema import WorkflowState, PICOQuery
55

6+
67
class AskAgent(BaseAgent):
78
"""Agent for refining clinical questions into PICO format"""
89

@@ -12,8 +13,10 @@ def __init__(self, llm, tools: List[Any] = None):
1213

1314
def _load_prompt(self) -> str:
1415
"""Load prompt template from file"""
15-
prompt_path = Path(__file__).parent.parent / "config" / "prompts" / "ask_agent.txt"
16-
with open(prompt_path, 'r', encoding='utf-8') as f:
16+
prompt_path = (
17+
Path(__file__).parent.parent / "config" / "prompts" / "ask_agent.txt"
18+
)
19+
with open(prompt_path, "r", encoding="utf-8") as f:
1720
return f.read()
1821

1922
def _parse_json(self, content: str) -> dict:
@@ -29,8 +32,7 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
2932
backtrack_context = f"Previous attempt failed: {state['backtrack_reason']}\nPlease refine the question."
3033

3134
prompt = self.prompt_template.format(
32-
question=question,
33-
backtrack_context=backtrack_context
35+
question=question, backtrack_context=backtrack_context
3436
)
3537

3638
response = self.llm.invoke(prompt)
@@ -41,7 +43,7 @@ def execute(self, state: WorkflowState) -> Dict[str, Any]:
4143
intervention=pico_dict["intervention"],
4244
comparison=pico_dict["comparison"],
4345
outcome=pico_dict["outcome"],
44-
keywords=pico_dict["keywords"]
46+
keywords=pico_dict["keywords"],
4547
)
4648

4749
question_type = pico_dict.get("question_type", "Therapy")

0 commit comments

Comments
 (0)