Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 25 additions & 53 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,59 +17,30 @@ env:
PIPENV_DONT_LOAD_ENV: "1"

jobs:
test:
name: Tests (${{ matrix.scope }})
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
fail-fast: false
matrix:
include:
- scope: core
paths: >-
tests/test_config_settings.py
tests/test_pipeline_core.py
tests/test_models.py
tests/test_paper_adapters.py
tests/test_memory.py
tests/test_progress_reporter.py
tests/test_message_formatting.py
timeout-minutes: 5

- scope: retrieval
paths: >-
tests/test_retrieval_stage.py
tests/test_export.py
tests/test_reporting.py
tests/test_embeddings.py
tests/test_providers.py
steps:
- name: Checkout
uses: actions/checkout@v4

- scope: research
paths: >-
tests/test_research_stages.py
tests/test_research_quality.py
tests/test_synthesis.py
tests/test_resolve_llm_features.py
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- scope: cli
paths: >-
tests/test_interactive_mode.py
tests/test_input_handler.py
tests/test_main_mode_detection.py
tests/test_signal_handling.py
tests/test_interactive_filters.py
tests/test_complete_workflow.py
- name: Install ruff
run: pip install ruff

- scope: llm
paths: >-
tests/test_llm_providers.py
tests/test_model_selection.py
tests/test_graceful_response_handling.py
tests/test_json_parsing_bug_exploration.py
tests/test_json_parsing_preservation.py
- name: Ruff check
run: ruff check src tests setups

- scope: api
paths: tests/test_phase3_extensibility.py
test:
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
Expand All @@ -78,20 +49,21 @@ jobs:
- name: Setup Pipenv
uses: ./.github/actions/setup-pipenv

# Auto-discovers every test file so new tests can never be silently skipped.
- name: Run tests
run: pipenv run pytest -m "not slow" --tb=short -q ${{ matrix.paths }}
run: pipenv run pytest tests/ -m "not slow" --tb=short -q

ci-complete:
name: CI complete
runs-on: ubuntu-latest
needs: test
needs: [lint, test]
if: always()

steps:
- name: Verify all scopes passed
- name: Verify all jobs passed
run: |
if [ "${{ needs.test.result }}" != "success" ]; then
echo "One or more test scopes failed."
if [ "${{ needs.lint.result }}" != "success" ] || [ "${{ needs.test.result }}" != "success" ]; then
echo "One or more CI jobs failed."
exit 1
fi
echo "All test scopes passed."
echo "All CI jobs passed."
461 changes: 80 additions & 381 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/_analysis/test-behavior-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ Source: all 28 files matching `tests/test_*.py`. Internal reference for `docs/de

| | |
|---|---|
| **Modules** | `src.utils.{response_models,retry_manager,quality_monitor,enhanced_validation,content_quality,json_processing,model_adaptation,fallback_processing}` |
| **Modules** | `src.utils.{response_models,retry_manager,quality_monitor,enhanced_validation,content_quality,model_adaptation,fallback_processing}` |

| Class | Behavior |
|-------|----------|
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ asyncio_mode = "auto"
markers = [
"slow: subprocess or end-to-end tests (excluded from default CI)",
]

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
# Correctness-focused: syntax errors, undefined names, unused imports/variables.
select = ["E9", "F"]
ignore = [
"F401", # __init__.py re-exports are intentional
]

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F811", "F841"]
4 changes: 4 additions & 0 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def ensure_setup() -> bool:
"""
import os

if os.environ.get("RA_SKIP_SETUP_CHECK", "").strip().lower() in {"1", "true", "yes"}:
logger.info("RA_SKIP_SETUP_CHECK is set; skipping environment setup checks.")
return True

if os.environ.get("PIPENV_ACTIVE") != "1":
logger.warning(
"Not running inside Pipenv. Use: pipenv run python -m src ... "
Expand Down
12 changes: 11 additions & 1 deletion src/analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# AI Research Assistant - Analysis Module
from typing import Any

from .gap_analysis import GapAnalysisStage, analyze_gaps
from .llm import analysis_agent
from .llm import get_analysis_agent
from .synthesis import SynthesisStage, extract_papers, run_synthesis, synthesize_collective
from ..retrieval.models import (
GapAnalysisResult,
Expand All @@ -21,6 +23,14 @@
"analysis_agent",
"analyze_gaps",
"extract_papers",
"get_analysis_agent",
"run_synthesis",
"synthesize_collective",
]


def __getattr__(name: str) -> Any:
# "analysis_agent" stays importable but is now built lazily on first access.
if name == "analysis_agent":
return get_analysis_agent()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
5 changes: 1 addition & 4 deletions src/analysis/gap_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING

from ..core.context import PipelineContext, StageResult
from ..models import AgentFactory, AgentRole, ROLE_SYSTEM_PROMPTS
from ..models import AgentFactory, AgentRole
from ..retrieval.models import GapAnalysisResult, PaperCluster, SynthesisResult
from ..utils.enhanced_response_handler import EnhancedResponseHandler
from ..utils.response_models import RequestContext, ResponseHandlerConfig
Expand All @@ -17,9 +17,6 @@
from ..config.settings import LLMConfig


GAP_ANALYSIS_SYSTEM_PROMPT = ROLE_SYSTEM_PROMPTS[AgentRole.GAP_ANALYSIS]


def resolve_synthesis_input(data: object, ctx: PipelineContext) -> SynthesisResult:
"""Resolve synthesis output even when an upstream stage passed the wrong type."""
from .synthesis import resolve_synthesis_input as _resolve
Expand Down
28 changes: 26 additions & 2 deletions src/analysis/llm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
# -*- coding: utf-8 -*-
"""Analysis agent configuration for the analysis module."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from ..models import AgentFactory, AgentRole

_factory = AgentFactory()
analysis_agent = _factory.create_agent(AgentRole.ANALYSIS)
if TYPE_CHECKING:
from pydantic_ai import Agent

_cached_agent: Agent | None = None


def get_analysis_agent() -> Agent:
"""Return the shared analysis agent, creating it on first use.

Lazily constructed so importing the analysis package has no side effects
(no settings resolution, no model creation).
"""
global _cached_agent
if _cached_agent is None:
_cached_agent = AgentFactory().create_agent(AgentRole.ANALYSIS)
return _cached_agent


def __getattr__(name: str) -> Any:
if name == "analysis_agent":
return get_analysis_agent()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
90 changes: 45 additions & 45 deletions src/analysis/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

from ..core.context import PipelineContext, StageResult
from ..core.paper_adapters import ensure_ranked_papers
from ..models import AgentFactory, AgentRole, ROLE_SYSTEM_PROMPTS
from ..research.query_expansion import extract_core_concepts
from ..models import AgentFactory, AgentRole, create_llm_agent
from ..research.text_utils import extract_core_concepts
from ..retrieval.models import (
PaperAnalysis,
PaperCluster,
Expand All @@ -29,13 +29,7 @@
from ..config.settings import LLMConfig, SynthesisConfig


def create_llm_agent(system_prompt: str, llm_config: LLMConfig | None = None) -> Agent:
"""Create a pydantic-ai agent from application LLM settings."""
return AgentFactory(llm_config).create_agent_with_prompt(system_prompt, llm_config)


EXTRACTION_SYSTEM_PROMPT = ROLE_SYSTEM_PROMPTS[AgentRole.EXTRACTION]
SYNTHESIS_SYSTEM_PROMPT = ROLE_SYSTEM_PROMPTS[AgentRole.SYNTHESIS]
__all__ = ["create_llm_agent", "SynthesisStage", "extract_papers", "synthesize_collective"]

HEURISTIC_DISAGREEMENT_PLACEHOLDER = (
"Cross-paper disagreement analysis limited in heuristic mode."
Expand Down Expand Up @@ -261,10 +255,6 @@ def _synthesis_handler_config(max_retries: int) -> ResponseHandlerConfig:
)


def _extraction_handler_config(max_retries: int) -> ResponseHandlerConfig:
return _synthesis_handler_config(max_retries)


def resolve_synthesis_input(data: object, ctx: PipelineContext) -> SynthesisResult:
"""Resolve a synthesis result from stage output or pipeline artifacts."""
artifact = ctx.get_artifact("synthesis_result")
Expand Down Expand Up @@ -367,41 +357,38 @@ async def extract_papers(

agent = AgentFactory(llm_config).create_agent(AgentRole.EXTRACTION, config=llm_config)
response_handler = handler or EnhancedResponseHandler(
_extraction_handler_config(synthesis_config.extraction_max_retries)
_synthesis_handler_config(synthesis_config.extraction_max_retries)
)
context = RequestContext(
user_query=query,
model_name=llm_config.model if llm_config else "default",
session_id=session_id,
)

semaphore = asyncio.Semaphore(max(concurrency, 1))
consecutive_failures = 0
circuit_open = False
llm_extractions: list[PaperExtraction] = []

for index, paper in enumerate(llm_targets, start=1):
if circuit_open:
llm_extractions.append(_heuristic_extraction(paper))
continue

title_preview = paper.paper.title[:72]
logger.info(
"LLM extracting paper %d/%d: %s",
index,
len(llm_targets),
paper.paper.title[:80],
)
from ..utils.progress_reporter import get_progress_reporter

from ..utils.progress_reporter import get_progress_reporter
semaphore = asyncio.Semaphore(max(concurrency, 1))
breaker = {"consecutive_failures": 0, "open": False}

reporter = get_progress_reporter()
if reporter is not None:
reporter.set_activity(
f"Analyzing paper {index}/{len(llm_targets)}: {title_preview}…"
async def _extract_with_circuit(index: int, paper: RankedPaper) -> PaperExtraction:
async with semaphore:
if breaker["open"]:
return _heuristic_extraction(paper)

title_preview = paper.paper.title[:72]
logger.info(
"LLM extracting paper %d/%d: %s",
index,
len(llm_targets),
paper.paper.title[:80],
)

async with semaphore:
reporter = get_progress_reporter()
if reporter is not None:
reporter.set_activity(
f"Analyzing paper {index}/{len(llm_targets)}: {title_preview}…"
)

extraction, llm_success = await _extract_single_paper(
paper,
query,
Expand All @@ -410,19 +397,32 @@ async def extract_papers(
context,
)

if not llm_success:
consecutive_failures += 1
if consecutive_failures >= synthesis_config.circuit_breaker_failures:
circuit_open = True
if llm_success:
breaker["consecutive_failures"] = 0
else:
breaker["consecutive_failures"] += 1
if (
not breaker["open"]
and breaker["consecutive_failures"]
>= synthesis_config.circuit_breaker_failures
):
breaker["open"] = True
logger.warning(
"LLM extraction circuit breaker open after %d failures; "
"using heuristics for remaining papers",
consecutive_failures,
breaker["consecutive_failures"],
)
else:
consecutive_failures = 0

llm_extractions.append(extraction)
return extraction

llm_extractions = list(
await asyncio.gather(
*(
_extract_with_circuit(index, paper)
for index, paper in enumerate(llm_targets, start=1)
)
)
)

heuristic_extractions = [_heuristic_extraction(paper) for paper in heuristic_targets]
return llm_extractions + heuristic_extractions
Expand Down
1 change: 1 addition & 0 deletions src/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ async def execute(
}

artifact_keys = (
"expanded_queries",
"ranked_papers",
"retrieved_papers",
"paper_analyses",
Expand Down
11 changes: 10 additions & 1 deletion src/embeddings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,25 @@
import numpy as np


def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Cosine similarity between two vectors."""
a_norm = np.linalg.norm(a)
b_norm = np.linalg.norm(b)
if a_norm == 0.0 or b_norm == 0.0:
return 0.0
return float(np.dot(a, b) / (a_norm * b_norm))


class EmbeddingProvider(ABC):
"""Interface for text embedding backends."""

@abstractmethod
def embed_texts(self, texts: list[str]) -> np.ndarray:
"""Return an array of shape ``(len(texts), dim)``."""

@abstractmethod
def similarity(self, a: np.ndarray, b: np.ndarray) -> float:
"""Return cosine similarity between two vectors."""
return cosine_similarity(a, b)

def embed_text(self, text: str) -> np.ndarray:
"""Embed a single text and return its vector."""
Expand Down
Loading
Loading