diff --git a/apps/worker/__init__.py b/apps/worker/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/worker/main.py b/apps/worker/main.py deleted file mode 100644 index 83b6d7d..0000000 --- a/apps/worker/main.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -import logging - -from core.logging import configure_logging - -configure_logging() -logger = logging.getLogger(__name__) - - -async def main(): - logger.info("Worker process starting. Awaiting async tasks...") - # In a real app, this might connect to a Redis queue or Celery broker - while True: - await asyncio.sleep(10) - logger.debug("Worker heartbeat...") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/core/config.py b/core/config.py index 441e6ee..fa764c1 100644 --- a/core/config.py +++ b/core/config.py @@ -81,7 +81,7 @@ class Settings(BaseSettings): @field_validator("max_budget_usd", mode="before") @classmethod def _coerce_budget(cls, v: Any) -> Optional[float]: - if not v: + if v is None or v == "": return None try: return float(v) diff --git a/core/logging.py b/core/logging.py index c510c22..ea0a1ce 100644 --- a/core/logging.py +++ b/core/logging.py @@ -13,7 +13,7 @@ Usage ----- Call ``configure_logging()`` once at each app entrypoint (``apps/api/``, -``apps/mcp_server/``, ``apps/worker/``). Do **not** call it in library code +``apps/mcp_server/``). Do **not** call it in library code or tests — tests rely on pytest's default capture. from core.logging import configure_logging diff --git a/core/orchestrator/thesis_flow.py b/core/orchestrator/thesis_flow.py index af6c1e0..937e108 100644 --- a/core/orchestrator/thesis_flow.py +++ b/core/orchestrator/thesis_flow.py @@ -37,11 +37,11 @@ ResearcherAgent, SynthesizerAgent, ThesisHeadProvider, - _build_placeholder_citation_audit, - _build_placeholder_critique_result, - _build_placeholder_lit_map, - _build_placeholder_research_plan, - _build_placeholder_synthesis_report, + build_placeholder_citation_audit, + build_placeholder_critique_result, + build_placeholder_lit_map, + build_placeholder_research_plan, + build_placeholder_synthesis_report, ) from providers.unified import UnifiedLLM @@ -157,7 +157,7 @@ async def _execute_inner(self, research_context: ResearchContext) -> ResearchSes async def _run_planner() -> ResearchPlan: if not route.activate_head_planner: obs_logger.log_event("stage_skipped", session_id, {"stage": "head_planner"}) - return _build_placeholder_research_plan(research_context.research_question) + return build_placeholder_research_plan(research_context.research_question) try: result = await self.head.execute( task, @@ -174,7 +174,7 @@ async def _run_planner() -> ResearchPlan: obs_logger.log_event( "stage_failed", session_id, {"stage": "head_planner", "error": str(e)} ) - return _build_placeholder_research_plan(research_context.research_question) + return build_placeholder_research_plan(research_context.research_question) async def _run_memory() -> MemoryBrief: try: @@ -277,12 +277,12 @@ async def _run_corpus() -> None: ) except Exception as e: errors.append(f"researcher: {e}") - lit_map = _build_placeholder_lit_map(research_context.research_question) + lit_map = build_placeholder_lit_map(research_context.research_question) obs_logger.log_event( "stage_failed", session_id, {"stage": "researcher", "error": str(e)} ) else: - lit_map = _build_placeholder_lit_map(research_context.research_question) + lit_map = build_placeholder_lit_map(research_context.research_question) obs_logger.log_event("stage_skipped", session_id, {"stage": "researcher"}) # ── Phase C: checker ∥ synthesizer (both consume lit_map, neither @@ -296,7 +296,7 @@ async def _run_corpus() -> None: async def _run_checker() -> CitationAudit: if not route.activate_checker: obs_logger.log_event("stage_skipped", session_id, {"stage": "checker"}) - return _build_placeholder_citation_audit() + return build_placeholder_citation_audit() try: result = await self.checker.execute(task, {"blackboard_entries": shared_entries}) audit = cast(CitationAudit, result["output"]) @@ -310,12 +310,12 @@ async def _run_checker() -> CitationAudit: obs_logger.log_event( "stage_failed", session_id, {"stage": "checker", "error": str(e)} ) - return _build_placeholder_citation_audit() + return build_placeholder_citation_audit() async def _run_synthesizer() -> SynthesisReport: if not route.activate_synthesizer: obs_logger.log_event("stage_skipped", session_id, {"stage": "synthesizer"}) - return _build_placeholder_synthesis_report(research_context.research_question) + return build_placeholder_synthesis_report(research_context.research_question) try: result = await self.synthesizer.execute( task, {"blackboard_entries": shared_entries} @@ -338,7 +338,7 @@ async def _run_synthesizer() -> SynthesisReport: obs_logger.log_event( "stage_failed", session_id, {"stage": "synthesizer", "error": str(e)} ) - return _build_placeholder_synthesis_report(research_context.research_question) + return build_placeholder_synthesis_report(research_context.research_question) citation_audit, synthesis_report = await asyncio.gather( _run_checker(), @@ -359,12 +359,12 @@ async def _run_synthesizer() -> SynthesisReport: ) except Exception as e: errors.append(f"critic: {e}") - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() obs_logger.log_event( "stage_failed", session_id, {"stage": "critic", "error": str(e)} ) else: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() obs_logger.log_event("stage_skipped", session_id, {"stage": "critic"}) # ── Stage 7: HEAD final pass (supervisor) ── @@ -383,12 +383,12 @@ async def _run_synthesizer() -> SynthesisReport: ) except Exception as e: errors.append(f"head_supervisor: {e}") - final_critique = _build_placeholder_critique_result() + final_critique = build_placeholder_critique_result() obs_logger.log_event( "stage_failed", session_id, {"stage": "head_supervisor", "error": str(e)} ) else: - final_critique = _build_placeholder_critique_result() + final_critique = build_placeholder_critique_result() obs_logger.log_event("stage_skipped", session_id, {"stage": "head_supervisor"}) # ── Stage 8: Assemble ResearchSession ── @@ -669,6 +669,6 @@ async def _critique_only(self, research_context: ResearchContext) -> CritiqueRes output: Any = result.get("output") if isinstance(output, CritiqueResult): return output - return _build_placeholder_critique_result() + return build_placeholder_critique_result() except Exception: - return _build_placeholder_critique_result() + return build_placeholder_critique_result() diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bfc1daa..bebf6fc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -114,7 +114,7 @@ This runs the full hook stack: trailing whitespace, YAML/JSON/TOML validity, lar ## Project layout ``` -apps/ Entry points — CLI, FastAPI server, MCP server, worker stub. +apps/ Entry points — CLI, FastAPI server, MCP server. Nothing in apps/ is imported by other packages. core/ Orchestrator, router, blackboard, memory, schemas, evals, sessions. diff --git a/docs/architecture.md b/docs/architecture.md index 07b97f0..1784b7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ Output format is driven by the `ENVIRONMENT` setting: Each entry carries `log_level`, `logger_name`, and an ISO-8601 timestamp. `structlog.contextvars` is included in the chain so any key bound with `structlog.contextvars.bind_contextvars()` (e.g., `session_id`) propagates to every log line emitted within that async context. -`configure_logging()` must be called once at each app entrypoint (`apps/api/`, `apps/mcp_server/`, `apps/worker/`). It must not be called in library code or tests. +`configure_logging()` must be called once at each app entrypoint (`apps/api/`, `apps/mcp_server/`). It must not be called in library code or tests. ## Embedding cache diff --git a/providers/adapters.py b/providers/adapters.py index c26f0a8..049fc8c 100644 --- a/providers/adapters.py +++ b/providers/adapters.py @@ -9,6 +9,7 @@ from core.orchestrator.compiler import PromptCompiler from core.schemas import Task from providers.interfaces import HeadProvider, MiddleProvider, WorkerProvider +from providers.placeholders import generate_placeholder_json from providers.unified import UnifiedLLM _PROVIDER_API_KEY_ATTR = { @@ -59,7 +60,7 @@ async def generate( if self.dry_run: await asyncio.sleep(0.01) if response_schema: - return _generate_placeholder_json(response_schema) + return generate_placeholder_json(response_schema) return f"[{self.provider.upper()}/{model_name} DRY_RUN] Processed: {prompt[:50]}..." if not self.api_key: raise ValueError(f"No API key configured for provider '{self.provider}'") @@ -166,25 +167,6 @@ async def _generate_openai( return str(response.choices[0].message.content) -def _generate_placeholder_json(schema: Dict[str, Any]) -> str: - props = schema.get("properties", {}) - required = schema.get("required", []) - result: Dict[str, Any] = {} - for key, prop in props.items(): - prop_type = prop.get("type", "string") - if prop_type == "string": - result[key] = "[DRY_RUN]" if key in required else "" - elif prop_type == "integer" or prop_type == "number": - result[key] = 0 - elif prop_type == "boolean": - result[key] = False - elif prop_type == "array": - result[key] = [] - elif prop_type == "object": - result[key] = {} - return json.dumps(result) - - def _get_available_providers() -> list[str]: from core.config import get_settings diff --git a/providers/placeholders.py b/providers/placeholders.py new file mode 100644 index 0000000..a712341 --- /dev/null +++ b/providers/placeholders.py @@ -0,0 +1,97 @@ +"""Shared placeholder builders used by adapters, unified, and thesis_flow. + +These helpers fabricate well-formed but empty results for two cases: +* ``generate_placeholder_json`` — DRY_RUN mode in the LLM adapter layer. +* ``build_placeholder_*`` — orchestrator fallback when an agent fails or + is skipped, so downstream stages always receive a valid object. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +from core.schemas import ( + CitationAudit, + CritiqueResult, + LitMap, + ResearchPlan, + SynthesisReport, +) + + +def generate_placeholder_json(schema: Dict[str, Any]) -> str: + props = schema.get("properties", {}) + required = schema.get("required", []) + result: Dict[str, Any] = {} + for key, prop in props.items(): + prop_type = prop.get("type", "string") + if prop_type == "string": + result[key] = "[DRY_RUN]" if key in required else "" + elif prop_type in ("integer", "number"): + result[key] = 0 + elif prop_type == "boolean": + result[key] = False + elif prop_type == "array": + result[key] = [] + elif prop_type == "object": + result[key] = {} + return json.dumps(result) + + +def build_placeholder_research_plan(question: str) -> ResearchPlan: + return ResearchPlan( + plan_id="placeholder-plan-001", + research_question=question, + subquestions=["What does the existing literature say?", "What methodological gaps exist?"], + strategy="broad_survey", + search_lanes=[ + { + "query": "placeholder search", + "source": "semantic_scholar", + "purpose": "initial survey", + }, + ], + evidence_needs=["literature review", "methodology assessment"], + budget_allocation={"max_searches": 5, "max_papers_per_search": 10}, + suggested_methodology="systematic review", + ) + + +def build_placeholder_lit_map(question: str) -> LitMap: + return LitMap( + research_question=question, + supporting=[], + challenging=[], + adjacent=[], + total_found=0, + search_query_used="placeholder_query", + ) + + +def build_placeholder_citation_audit() -> CitationAudit: + return CitationAudit(claims_checked=0, verified_claims=0) + + +def build_placeholder_synthesis_report(question: str = "") -> SynthesisReport: + return SynthesisReport( + research_question=question, + method_summary={}, + dataset_summary={}, + metric_summary={}, + corpus_insights={}, + recommended_reading=[], + cross_paper_comparisons=[], + ) + + +def build_placeholder_critique_result() -> CritiqueResult: + return CritiqueResult( + strengths=[], + weaknesses=[], + gaps=[], + counterarguments=[], + suggestions=[], + methodological_notes=[], + overall_assessment="", + ) diff --git a/providers/thesis_agents.py b/providers/thesis_agents.py index 032bbc8..f43fcc4 100644 --- a/providers/thesis_agents.py +++ b/providers/thesis_agents.py @@ -16,11 +16,32 @@ SynthesisReport, Task, ) +from providers.placeholders import ( + build_placeholder_citation_audit, + build_placeholder_critique_result, + build_placeholder_lit_map, + build_placeholder_research_plan, + build_placeholder_synthesis_report, +) from providers.unified import UnifiedLLM logger = logging.getLogger(__name__) +__all__ = [ + "CheckerAgent", + "CriticAgent", + "ResearcherAgent", + "SynthesizerAgent", + "ThesisHeadProvider", + "build_placeholder_citation_audit", + "build_placeholder_critique_result", + "build_placeholder_lit_map", + "build_placeholder_research_plan", + "build_placeholder_synthesis_report", +] + + _MODEL_SCHEMAS: Dict[Type[BaseModel], Dict[str, Any]] = {} _SCHEMA_NAMES: Dict[Type[BaseModel], str] = { ResearchPlan: "ResearchPlan", @@ -117,64 +138,6 @@ def _parse_structured_output( return dict_converter(parsed_dict) -def _build_placeholder_research_plan(question: str) -> ResearchPlan: - return ResearchPlan( - plan_id="placeholder-plan-001", - research_question=question, - subquestions=["What does the existing literature say?", "What methodological gaps exist?"], - strategy="broad_survey", - search_lanes=[ - { - "query": "placeholder search", - "source": "semantic_scholar", - "purpose": "initial survey", - }, - ], - evidence_needs=["literature review", "methodology assessment"], - budget_allocation={"max_searches": 5, "max_papers_per_search": 10}, - suggested_methodology="systematic review", - ) - - -def _build_placeholder_lit_map(question: str) -> LitMap: - return LitMap( - research_question=question, - supporting=[], - challenging=[], - adjacent=[], - total_found=0, - search_query_used="placeholder_query", - ) - - -def _build_placeholder_citation_audit() -> CitationAudit: - return CitationAudit(claims_checked=0, verified_claims=0) - - -def _build_placeholder_synthesis_report(question: str = "") -> SynthesisReport: - return SynthesisReport( - research_question=question, - method_summary={}, - dataset_summary={}, - metric_summary={}, - corpus_insights={}, - recommended_reading=[], - cross_paper_comparisons=[], - ) - - -def _build_placeholder_critique_result() -> CritiqueResult: - return CritiqueResult( - strengths=[], - weaknesses=[], - gaps=[], - counterarguments=[], - suggestions=[], - methodological_notes=[], - overall_assessment="", - ) - - class ThesisHeadProvider: def __init__(self, unified: UnifiedLLM, compiler: Optional[PromptCompiler] = None): self.unified = unified @@ -212,7 +175,7 @@ async def _execute_planner(self, task: Task, entries: List[BlackboardEntry]) -> if task.research_context else task.description ) - plan = _build_placeholder_research_plan(question) + plan = build_placeholder_research_plan(question) else: plan = _parse_structured_output(response.content, ResearchPlan, _dict_to_research_plan) @@ -244,7 +207,7 @@ async def _execute_supervisor( ) if response.dry_run: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() else: critique = _parse_structured_output( response.content, CritiqueResult, _dict_to_critique_result @@ -286,7 +249,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - lit_map = _build_placeholder_lit_map(question) + lit_map = build_placeholder_lit_map(question) else: lit_map = _parse_structured_output(response.content, LitMap, _dict_to_lit_map) @@ -323,7 +286,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - audit = _build_placeholder_citation_audit() + audit = build_placeholder_citation_audit() else: audit = _parse_structured_output( response.content, CitationAudit, _dict_to_citation_audit @@ -365,7 +328,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> if response.dry_run: question = task.research_context.research_question if task.research_context else "" - report = _build_placeholder_synthesis_report(question) + report = build_placeholder_synthesis_report(question) else: report = _parse_structured_output( response.content, SynthesisReport, _dict_to_synthesis_report @@ -404,7 +367,7 @@ async def execute(self, task: Task, context: Optional[Dict[str, Any]] = None) -> ) if response.dry_run: - critique = _build_placeholder_critique_result() + critique = build_placeholder_critique_result() else: critique = _parse_structured_output( response.content, CritiqueResult, _dict_to_critique_result diff --git a/providers/unified.py b/providers/unified.py index 572466f..0d9c61e 100644 --- a/providers/unified.py +++ b/providers/unified.py @@ -1,4 +1,3 @@ -import json import logging import time from dataclasses import dataclass, field @@ -7,6 +6,7 @@ import pybreaker from providers.budget import BudgetExceededError, get_current_guard +from providers.placeholders import generate_placeholder_json from providers.resilience import ( ProviderBreakerRegistry, call_with_resilience, @@ -44,25 +44,6 @@ class LLMResponse: } -def _generate_placeholder_json(schema: Dict[str, Any]) -> str: - props = schema.get("properties", {}) - required = schema.get("required", []) - result: Dict[str, Any] = {} - for key, prop in props.items(): - prop_type = prop.get("type", "string") - if prop_type == "string": - result[key] = "[DRY_RUN]" if key in required else "" - elif prop_type in ("integer", "number"): - result[key] = 0 - elif prop_type == "boolean": - result[key] = False - elif prop_type == "array": - result[key] = [] - elif prop_type == "object": - result[key] = {} - return json.dumps(result) - - def _read_mode_config(mode: str, dry_run: bool = False) -> tuple[str, str]: from core.config import get_settings @@ -164,7 +145,7 @@ async def generate( elapsed = (time.monotonic() - t0) * 1000 content = f"[DRY_RUN mode={mode} provider={preferred_provider} model={preferred_model}] Would call {preferred_provider}/{preferred_model}. Prompt: {prompt[:80]}..." if response_schema: - content = _generate_placeholder_json(response_schema) + content = generate_placeholder_json(response_schema) response = LLMResponse( content=content, provider_used=preferred_provider, diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 081dd03..05994b8 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -7,7 +7,8 @@ from core.evals.harness import EvaluationHarness from core.schemas import CitationAudit, CritiqueResult, LitMap, ResearchPlan, SynthesisReport -from providers.adapters import LLMAdapter, _generate_placeholder_json +from providers.adapters import LLMAdapter +from providers.placeholders import generate_placeholder_json from tools.mcp.engine import ToolRegistry @@ -50,11 +51,11 @@ async def test_adapters_dry_run(monkeypatch): def test_placeholder_json_validates_pydantic_models(): - """Placeholder JSON from _generate_placeholder_json passes Pydantic model_validate_json.""" + """Placeholder JSON from generate_placeholder_json passes Pydantic model_validate_json.""" for model_cls in (ResearchPlan, LitMap, CitationAudit, SynthesisReport, CritiqueResult): schema = model_cls.model_json_schema() schema.pop("title", None) - raw = _generate_placeholder_json(schema) + raw = generate_placeholder_json(schema) parsed = json.loads(raw) assert isinstance(parsed, dict), f"{model_cls.__name__} placeholder is not a JSON object" instance = model_cls.model_validate_json(raw) @@ -107,13 +108,13 @@ def test_structured_output_schema_includes_all_required_fields(): schema.pop("title", None) required = schema.get("required", []) - raw = json.loads(_generate_placeholder_json(schema)) + raw = json.loads(generate_placeholder_json(schema)) for key in required: assert key in raw, f"Required key '{key}' missing from placeholder for CitationAudit" schema2 = LitMap.model_json_schema() schema2.pop("title", None) - raw2 = json.loads(_generate_placeholder_json(schema2)) + raw2 = json.loads(generate_placeholder_json(schema2)) for key in schema2.get("required", []): assert key in raw2, f"Required key '{key}' missing from placeholder for LitMap"