content: import asyncio
  import importlib.resources
  import logging
  import os
  import pathlib
  import re
  import warnings
  from collections import defaultdict
  from collections.abc import Awaitable, Callable, Mapping, Sequence
  from enum import IntEnum, StrEnum
  from itertools import starmap
  from pydoc import locate
  from typing import (
      Any,
      ClassVar,
      Protocol,
      Self,
      TypeAlias,
      assert_never,
      cast,
      runtime_checkable,
  )
  
  import anyio
  import litellm
  from aviary.core import Message, Tool, ToolSelector
  from lmi import (
      CommonLLMNames,
      EmbeddingModel,
      LiteLLMModel,
      embedding_model_factory,
  )
  from pydantic import (
      BaseModel,
      ConfigDict,
      Field,
      SerializerFunctionWrapHandler,
      computed_field,
      field_validator,
      model_serializer,
      model_validator,
  )
  from pydantic_core.core_schema import SerializationInfo
  from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
  
  import paperqa.configs
  from paperqa._ldp_shims import (
      HAS_LDP_INSTALLED,
      Agent,
      HTTPAgentClient,
      MemoryAgent,
      ReActAgent,
      SimpleAgent,
      SimpleAgentState,
      UIndexMemoryModel,
      _Memories,
      set_training_mode,
  )
  from paperqa.prompts import (
      CONTEXT_INNER_PROMPT,
      CONTEXT_OUTER_PROMPT,
      answer_iteration_prompt_template,
      citation_prompt,
      default_system_prompt,
      env_reset_prompt,
      env_system_prompt,
      individual_media_enrichment_prompt_template,
      qa_prompt,
      select_paper_prompt,
      structured_citation_prompt,
      summary_json_prompt,
      summary_json_system_prompt,
      summary_prompt,
  )
  from paperqa.readers import PDFParserFn
  from paperqa.types import Context, ParsedMedia, ParsedText
  from paperqa.utils import hexdigest, parse_enrichment_irrelevance, pqa_directory
  
  logger = logging.getLogger(__name__)
  
  # TODO: move to actual EnvironmentState
  # when we can do so without a circular import
  _EnvironmentState: TypeAlias = Any
  
  
  @runtime_checkable
  class AsyncContextSerializer(Protocol):
      """Protocol for generating a context string from settings and context."""
  
      async def __call__(
          self,
          settings: "Settings",
          contexts: Sequence[Context],
          question: str,
          pre_str: str | None,
      ) -> str: ...
  
  
  class AnswerSettings(BaseModel):
      model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
  
      evidence_k: int = Field(
          default=10, description="Number of evidence pieces to retrieve."
      )
      evidence_retrieval: bool = Field(
          default=True,
          description="Whether to use retrieval instead of processing all docs.",
      )
      # no validator because you can set the range in a prompt
      evidence_relevance_score_cutoff: int = Field(
          default=1,
          ge=0,
          description=(
              "Relevance score cutoff for evidence retrieval, default is 1, meaning"
              " only evidence with relevance score >= 1 will be used."
          ),
      )
      evidence_summary_length: str = Field(
          default="about 100 words", description="Length of evidence summary."
      )
      evidence_skip_summary: bool = Field(
          default=False, description="Whether to summarization."
      )
      evidence_text_only_fallback: bool = Field(
          default=False,
          description=(
              "Opt-in flag to allow creating contexts without media (just text),"
              " if the media is problematic for the LLM provider or network."
          ),
      )
      answer_max_sources: int = Field(
          default=5, description="Max number of sources to use for an answer."
      )
      max_answer_attempts: int | None = Field(
          default=None,
          description=(
              "Optional (exclusive) max number (default is no max) of attempts to"
              " generate an answer before declaring done (without a complete tool call). "
          ),
      )
      answer_length: str = Field(
          default="about 200 words, but can be longer",
          description="Length of final answer.",
      )
      max_concurrent_requests: int = Field(
          default=4, description="Max concurrent requests to LLMs."
      )
      answer_filter_extra_background: bool = Field(
          default=False,
          description="Whether to cite background information provided by model.",
      )
      get_evidence_if_no_contexts: bool = Field(
          default=True,
          description=(
              "Opt-out flag for allowing answer generation to lazily gather evidence if"
              " called before evidence was gathered."
          ),
      )
      group_contexts_by_question: bool = Field(
          default=False,
          description="Whether to group contexts by question when generating answers.",
      )
      skip_evidence_citation_strip: bool = Field(
          default=False,
          description="Whether to skip stripping citations from evidence.",
      )
  
  
  class ChunkingOptions(StrEnum):
      SIMPLE_OVERLAP = "simple_overlap"
  
  
  def get_default_pdf_parser() -> PDFParserFn:
      parse_pdf_to_pages: PDFParserFn
  
      try:
          from paperqa_pymupdf import parse_pdf_to_pages
      except ImportError:
          try:
              from paperqa_pypdf import parse_pdf_to_pages  # type: ignore[no-redef,unused-ignore]
          except ImportError as exc:
              raise ImportError(
                  "To parse PDFs we need a parsing function. Please install either:"
                  " (1) paper-qa-pypdf via `pip install paper-qa[pypdf]` or"
                  " (2) paper-qa-pymupdf via `pip install paper-qa[pymupdf]`."
              ) from exc
  
      return parse_pdf_to_pages
  
  
  def default_pdf_parser_configurator() -> None:
      try:
          from paperqa_pymupdf import setup_pymupdf_python_logging
      except ImportError:
          return
  
      setup_pymupdf_python_logging()
  
  
  class MultimodalOptions(IntEnum):
      # Text-only PDF reads
      OFF = 0  # Falsey
      ON_WITH_ENRICHMENT = 1  # Default
      # Without image enrichment, multimodal will miss retrieval of certain images,
      # but it costs less money (no enrichment LLM calls)
      ON_WITHOUT_ENRICHMENT = 2
  
      @classmethod
      def from_value(cls, value: "bool | MultimodalOptions") -> "MultimodalOptions":
          if isinstance(value, bool):
              return cls(int(value))
          return value
  
      @property
      def should_parse_and_enrich_media(self) -> tuple[bool, bool]:
          """Get if the settings indicate to parse and also enrich media."""
          if self == MultimodalOptions.OFF:
              return False, False
          if self == MultimodalOptions.ON_WITHOUT_ENRICHMENT:
              return True, False
          if self == MultimodalOptions.ON_WITH_ENRICHMENT:
              return True, True
          assert_never(self)
  
  
  class ParsingSettings(BaseModel):
      """Settings relevant for parsing and chunking documents."""
  
      model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
  
      page_size_limit: int | None = Field(
          default=1_280_000,
          description=(
              "Optional limit on the number of characters to parse in one 'page', default"
              " is 1.28 million chars, 10X larger than a 128k tokens context limit"
              " (ignoring chars vs tokens difference)."
          ),
      )
      use_doc_details: bool = Field(
          default=True, description="Whether to try to get metadata details for a Doc."
      )
      reader_config: dict[str, Any] = Field(
          default_factory=lambda: {"chunk_chars": 5000, "overlap": 250},
          description="Optional keyword arguments for the document reader.",
          examples=[{"dpi": 300}],
      )
      multimodal: bool | MultimodalOptions = Field(
          default=MultimodalOptions.ON_WITH_ENRICHMENT,
          description=(
              "Controls on parsing images/tables (if applicable to a given document)."
              " Setting false or off will parse only text,"
              " setting true or 'on with enrichment' will parse media and use"
              " the enrichment LLM to generate descriptions of the media,"
              " setting 'on without enrichment' will parse media without enrichment."
          ),
      )
      citation_prompt: str = Field(
          default=citation_prompt,
          description="Prompt that tries to create citation from peeking one chunk.",
      )
      structured_citation_prompt: str = Field(
          default=structured_citation_prompt,
          description=(
              "Prompt that tries to creates a citation in JSON from peeking one chunk."
          ),
      )
      disable_doc_valid_check: bool = Field(
          default=False,
          description=(
              "Whether to disable checking if a document looks like text (was parsed"
              " correctly)."
          ),
      )
      defer_embedding: bool = Field(
          default=False,
          description=(
              "Whether to embed documents immediately as they are added, or defer until"
              " summarization."
          ),
      )
      parse_pdf: PDFParserFn = Field(
          default_factory=get_default_pdf_parser,
          description="Function to parse PDF, or a fully qualified name to import.",
          examples=["paperqa_docling.parse_pdf_to_pages"],
          exclude=True,  # NOTE: a custom serializer is used below, so it's not excluded
      )
      configure_pdf_parser: Callable[[], Any] = Field(
          default=default_pdf_parser_configurator,
          description=(
              "Callable to configure the PDF parser within parse_pdf,"
              " useful for behaviors such as enabling logging."
          ),
          exclude=True,
      )
  
      @field_validator("parse_pdf", mode="before")
      @classmethod
      def _resolve_parse_pdf(cls, v: str | PDFParserFn) -> PDFParserFn:
          """Resolve a fully qualified name to a parser function."""
          if isinstance(v, str):
              resolved = locate(v)
              if resolved is None:
                  raise ValueError(f"Failed to locate PDF parser function {v!r}.")
              if not isinstance(resolved, PDFParserFn):
                  raise TypeError(f"Value {v!r} is not a PDF parser function.")
              return resolved
          return v
  
      @model_serializer(mode="wrap")
      def _custom_serializer(
          self, serializer: SerializerFunctionWrapHandler, info: SerializationInfo
      ) -> dict[str, Any]:
          data = serializer(self)
          # NOTE: due to parse_pdf's exclude=True flag, it's not yet in this data.
          # Let's now add it back if we can safely deserialize "over the network"
          if isinstance(self.parse_pdf, str):
              # Already JSON-compliant, so let's un-exclude
              data["parse_pdf"] = self.parse_pdf
          elif (
              info.mode == "json"
              and hasattr(self.parse_pdf, "__module__")
              and hasattr(self.parse_pdf, "__name__")
          ):
              # If going to JSON, and we can get a FQN, do so for JSON compliance
              data["parse_pdf"] = f"{self.parse_pdf.__module__}.{self.parse_pdf.__name__}"
          return data
  
      doc_filters: Sequence[Mapping[str, Any]] | None = Field(
          default=None,
          description=(
              "Optional filters to only allow documents that match this filter. This is a"
              " dictionary where the keys are the fields from DocDetails or Docs to"
              " filter on, and the values are the values to filter for. To invert filter,"
              " prefix the key with a '!'. If the key is not found, by default the Doc is"
              " rejected. To change this behavior, prefix the key with a '?' to allow the"
              " Doc to pass if the key is not found. For example, {'!title': 'bad title',"
              " '?year': '2022'} would only allow Docs with a title that is not 'bad"
              " title' and a year of 2022 or no year at all."
          ),
      )
      use_human_readable_clinical_trials: bool = Field(
          default=False,
          description="Parse clinical trial JSONs into human readable text.",
      )
      enrichment_llm: str = Field(
          # NOTE: from CapArena (https://arxiv.org/abs/2503.12329),
          # GPT-4o was the best image captioning model as of spring 2025
          # NOTE: claude-haiku-4-5-20251001 recurringly failed to describe display type
          # to be display type, so its captioning ability isn't good enough yet
          default=CommonLLMNames.GPT_4O.value,
          description="LLM for media enrichment (e.g. generating descriptions).",
      )
      enrichment_llm_config: dict | None = Field(
          default=None,
          description=(
              "Optional configuration for the enrichment_llm model. More specifically, it's"
              " a LiteLLM Router configuration to pass to LiteLLMModel, must have"
              " `model_list` key (corresponding to model_list inputs here:"
              " https://docs.litellm.ai/docs/routing), and can optionally include a"
              " router_kwargs key with router kwargs as values."
          ),
      )
      enrichment_page_radius: int = Field(
          default=1,  # Default is 1 because figures are usually +/- 1 page in LaTeX
          ge=-1,
          description=(
              "Page radius for context text in enrichment. "
              "-1 means all pages, 0 means current page only, "
              "1+ means a radius of pages around the current page."
          ),
      )
      enrichment_prompt: str = Field(
          default=individual_media_enrichment_prompt_template,
          description="Prompt template for enriching media.",
      )
  
      @property
      def should_parse_and_enrich_media(self) -> tuple[bool, bool]:
          """Get if the settings indicate to parse and also enrich media."""
          mm_enum = MultimodalOptions.from_value(self.multimodal)
          return mm_enum.should_parse_and_enrich_media
  
  
  class _FormatDict(dict):  # noqa: FURB189
      """Mock a dictionary and store any missing items."""
  
      def __init__(self) -> None:
          self.key_set: set[str] = set()
  
      def __missing__(self, key: str) -> str:
          self.key_set.add(key)
          return key
  
  
  def get_formatted_variables(s: str) -> set[str]:
      """Returns the set of variables implied by the format string."""
      format_dict = _FormatDict()
      s.format_map(format_dict)
      return format_dict.key_set
  
  
  class PromptSettings(BaseModel):
      model_config = ConfigDict(extra="forbid", validate_assignment=True)
  
      # citations are inserted with Context.id as follows,
      # these are translated to MLA parenthetical in-text citation styling
      # SEE: https://nwtc.libguides.com/citations/MLA#s-lg-box-707489
      EXAMPLE_CITATION: ClassVar[str] = "(pqac-0f650d59)"
  
      summary: str = summary_prompt
      qa: str = qa_prompt
      answer_iteration_prompt: str | None = Field(
          default=answer_iteration_prompt_template,
          description=(
              "Prompt to inject existing prior answers into the qa prompt to allow the model to iterate. "
              "If None, then no prior answers will be injected."
          ),
      )
      select: str = select_paper_prompt
      pre: str | None = Field(
          default=None,
          description=(
              "Opt-in pre-prompt (templated with just the original question) to append"
              " information before a qa prompt. For example:"
              " 'Summarize all scientific terms in the following question:\n{question}'."
              " This pre-prompt can enable injection of question-specific guidance later"
              " used by the qa prompt, without changing the qa prompt's template."
          ),
      )
      post: str | None = None
      system: str = default_system_prompt
      use_json: bool = True
      # Not thrilled about this model,
      # but need to split out the system/summary
      # to get JSON
      summary_json: str = summary_json_prompt
      summary_json_system: str = summary_json_system_prompt
      context_outer: str = Field(
          default=CONTEXT_OUTER_PROMPT,
          description="Prompt for how to format all contexts in generate answer.",
      )
      context_inner: str = Field(
          default=CONTEXT_INNER_PROMPT,
          description=(
              "Prompt for how to format a single context in generate answer. "
              "This should at least contain key and name."
          ),
      )
  
      @field_validator("summary")
      @classmethod
      def check_summary(cls, v: str) -> str:
          if not get_formatted_variables(v).issubset(
              get_formatted_variables(summary_prompt)
          ):
              raise ValueError(
                  "Summary prompt can only have variables:"
                  f" {get_formatted_variables(summary_prompt)}"
              )
          return v
  
      @field_validator("qa")
      @classmethod
      def check_qa(cls, v: str) -> str:
          if not get_formatted_variables(v).issubset(get_formatted_variables(qa_prompt)):
              raise ValueError(
                  "QA prompt can only have variables:"
                  f" {get_formatted_variables(qa_prompt)}"
              )
          return v
  
      @field_validator("select")
      @classmethod
      def check_select(cls, v: str) -> str:
          if not get_formatted_variables(v).issubset(
              get_formatted_variables(select_paper_prompt)
          ):
              raise ValueError(
                  "Select prompt can only have variables:"
                  f" {get_formatted_variables(select_paper_prompt)}"
              )
          return v
  
      @field_validator("post")
      @classmethod
      def check_post(cls, v: str | None) -> str | None:
          if v is not None:
              # kind of a hack to get list of attributes in answer
              from paperqa.types import PQASession
  
              attrs = set(PQASession.model_fields.keys())
              if not get_formatted_variables(v).issubset(attrs):
                  raise ValueError(f"Post prompt must have input variables: {attrs}")
          return v
  
      @field_validator("context_outer")
      @classmethod
      def check_context_outer(cls, v: str) -> str:
          if not get_formatted_variables(v).issubset(
              get_formatted_variables(CONTEXT_OUTER_PROMPT)
          ):
              raise ValueError(
                  "Context outer prompt can only have variables:"
                  f" {get_formatted_variables(CONTEXT_OUTER_PROMPT)}"
              )
          return v
  
      @field_validator("context_inner")
      @classmethod
      def check_context_inner(cls, v: str) -> str:
          fvars = get_formatted_variables(v)
          if "name" not in fvars or "text" not in fvars:
              raise ValueError("Context inner prompt must have name and text")
          return v
  
  
  class IndexSettings(BaseModel):
      model_config = ConfigDict(extra="forbid")
  
      name: str | None = Field(
          default=None,
          description=(
              "Optional name of the index. If unspecified, the name should be generated."
          ),
      )
      paper_directory: str | os.PathLike = Field(
          default=pathlib.Path.cwd(),
          description=(
              "Local directory which contains the papers to be indexed and searched."
          ),
      )
      manifest_file: str | os.PathLike | None = Field(
          default=None,
          description=(
              "Optional absolute path to a manifest CSV, or a relative path from the"
              " paper_directory to a manifest CSV. A manifest CSV contains columns which"
              " are attributes for a DocDetails object. Only 'file_location', 'doi', and"
              " 'title' will be used when indexing, others are discarded."
          ),
      )
      index_directory: str | os.PathLike = Field(
          default_factory=lambda: pqa_directory("indexes"),
          description=(
              "Directory to store the PQA built search index, configuration, and"
              " answer indexes."
          ),
      )
      use_absolute_paper_directory: bool = Field(
          default=False,
          description=(
              "Opt-in flag to convert the paper_directory to an absolute path. Setting"
              " this to True will make the index user-specific, defeating sharing."
          ),
      )
      recurse_subdirectories: bool = Field(
          default=True,
          description="Whether to recurse into subdirectories when indexing sources.",
      )
      concurrency: int = Field(
          default=5,  # low default for folks without S2/Crossref keys
          description="Number of concurrent filesystem reads for indexing",
      )
      batch_size: int = Field(
          default=1,
          ge=1,
          description="Number of files to process before committing to the index.",
      )
      sync_with_paper_directory: bool = Field(
          default=True,
          description=(
              "Whether to sync the index with the paper directory when loading an index."
              " Setting to True will add or delete index files to match the source paper"
              " directory."
          ),
      )
      files_filter: Callable[[anyio.Path | pathlib.Path], bool] = Field(
          default=lambda f: (
              f.suffix
              # TODO: add images after embeddings are supported
              in {".txt", ".pdf", ".html", ".md", ".xlsx", ".docx", ".pptx"}
          ),
          exclude=True,
          description=(
              "Filter function to apply to files in the paper directory."
              " When the function returns True, the file will be indexed."
          ),
      )
  
      def get_named_index_directory(self) -> anyio.Path:
          """Get the directory where the index, when named, will be located.
  
          Raises:
              ValueError: If the index name was unset, because otherwise the name is
                  autogenerated.
          """
          if self.name is None:
              raise ValueError(
                  "Getting a named index directory requires an index name to have been"
                  " specified, please specify a name."
              )
          return anyio.Path(self.index_directory) / self.name
  
      async def finalize_manifest_file(self) -> anyio.Path | None:
          manifest_file = anyio.Path(self.manifest_file) if self.manifest_file else None
          if manifest_file and not await manifest_file.exists():
              # If the manifest file was specified but doesn't exist,
              # perhaps it was specified as a relative path from the paper_directory
              manifest_file = anyio.Path(self.paper_directory) / manifest_file
          return manifest_file
  
  
  class AgentSettings(BaseModel):
      model_config = ConfigDict(extra="forbid")
  
      agent_llm: str = Field(
          default=CommonLLMNames.GPT_4O.value,
          description="LLM inside the agent making tool selections.",
      )
  
      agent_llm_config: dict | None = Field(
          default=None,
          description=(
              "Optional configuration for the agent_llm model. More specifically, it's"
              " a LiteLLM Router configuration to pass to LiteLLMModel, must have"
              " `model_list` key (corresponding to model_list inputs here:"
              " https://docs.litellm.ai/docs/routing), and can optionally include a"
              " router_kwargs key with router kwargs as values."
          ),
      )
  
      agent_type: str = Field(
          default="ToolSelector",
          description="Type of agent to use",
      )
      agent_config: dict[str, Any] | None = Field(
          default=None,
          description="Optional kwarg for AGENT constructor.",
      )
      agent_system_prompt: str | None = Field(
          default=env_system_prompt,
          description="Optional system prompt message to precede the below agent_prompt.",
      )
      agent_prompt: str = env_reset_prompt
      return_paper_metadata: bool = Field(
          default=False,
          description=(
              "Set True to have the search tool include paper title/year information as"
              " part of its return."
          ),
      )
      search_count: int = 8
      agent_evidence_n: int = Field(
          default=1,
          ge=1,
          description=(
              "Top n ranked evidences shown to the agent after the GatherEvidence tool."
          ),
      )
      timeout: float = Field(
          default=500.0,
          description=(
              "Matches LangChain AgentExecutor.max_execution_time (seconds), the timeout"
              " on agent execution."
          ),
      )
  
      tool_names: set[str] | Sequence[str] | None = Field(
          default=None,
          description=(
              "Optional override on the tools to provide the agent. Leaving as the"
              " default of None will use a minimal toolset of the paper search, gather"
              " evidence, collect cited papers from evidence, and gen answer. If passing"
              " tool names (non-default route), at least the gen answer tool must be"
              " supplied."
          ),
      )
      max_timesteps: int | None = Field(
          default=None,
          description="Optional upper limit on the number of environment steps.",
      )
  
      index: IndexSettings = Field(default_factory=IndexSettings)
  
      rebuild_index: bool = Field(
          default=True,
          description=(
              "Flag to rebuild the index at the start of agent runners, default is True"
              " for CLI users to ensure all source PDFs are pulled in."
          ),
      )
  
      callbacks: Mapping[str, Sequence[Callable[[_EnvironmentState], Any]]] = Field(
          default_factory=dict,
          description="""
              A mapping that associates callback names with lists of corresponding callable functions.
              Each callback list contains functions that will be called with an instance of `EnvironmentState`,
              representing the current state context.
  
              Accepted callback names:
              - 'gen_answer_initialized': Triggered when `GenerateAnswer.gen_answer`
                  is initialized.
  
              - 'gen_answer_aget_query': LLM callbacks to execute in the prompt runner
                  as part of `GenerateAnswer.gen_answer`.
  
              - 'gen_answer_completed': Triggered after `GenerateAnswer.gen_answer`
                  successfully generates an answer.
  
              - 'gather_evidence_initialized': Triggered when `GatherEvidence.gather_evidence`
                  is initialized.
  
              - 'gather_evidence_aget_evidence: LLM callbacks to execute in the prompt runner
                  as part of `GatherEvidence.gather_evidence`.
  
              - 'gather_evidence_completed': Triggered after `GatherEvidence.gather_evidence`
                  completes evidence gathering.
          """,
          exclude=True,
      )
  
  
  def make_default_litellm_model_list_settings(
      llm: str, temperature: float = 0.0
  ) -> dict:
      """Settings matching "model_list" schema here: https://docs.litellm.ai/docs/routing."""
      return {
          "name": llm,
          "model_list": [
              {
                  "model_name": llm,
                  "litellm_params": {"model": llm, "temperature": temperature},
              }
          ],
      }
  
  
  class Settings(BaseSettings):
      model_config = SettingsConfigDict(extra="ignore")
  
      llm: str = Field(
          default=CommonLLMNames.GPT_4O.value,
          description=(
              "LLM for general use including metadata inference (see Docs.aadd)"
              " and answer generation (see Docs.aquery and gen_answer tool)."
              " Should be 'best' LLM. Uses include:"
              " 1. Inferring citation information from documents when left unspecified,"
              " 2. Extracting title, DOI, and authors from citation information when left unspecified,"
              " 3. Optionally injecting pre-answer information (see PromptSettings.pre),"
              " 4. Generating an answer given evidence (see PromptSettings.qa),"
              " 5. Optionally injecting post-answer information (see PromptSettings.post),"
              " 6. If using the 'fake' agent, proposing search queries."
          ),
      )
      llm_config: dict | None = Field(
          default=None,
          description=(
              "Optional configuration for the llm model. More specifically, it's"
              " a LiteLLM Router configuration to pass to LiteLLMModel, must have"
              " `model_list` key (corresponding to model_list inputs here:"
              " https://docs.litellm.ai/docs/routing), and can optionally include a"
              " router_kwargs key with router kwargs as values."
          ),
      )
      summary_llm: str = Field(
          default=CommonLLMNames.GPT_4O.value,
          description=(
              "LLM for creating contextual summaries"
              " (see Docs.aget_evidence and gather_evidence tool)."
          ),
      )
      summary_llm_config: dict | None = Field(
          default=None,
          description=(
              "Optional configuration for the summary_llm model. More specifically, it's"
              " a LiteLLM Router configuration to pass to LiteLLMModel, must have"
              " `model_list` key (corresponding to model_list inputs here:"
              " https://docs.litellm.ai/docs/routing), and can optionally include a"
              " router_kwargs key with router kwargs as values."
          ),
      )
      embedding: str = Field(
          default="text-embedding-3-small",
          description="Embedding model for embedding text chunks when adding papers.",
      )
      embedding_config: dict | None = Field(
          default=None,
          description="Optional configuration for the embedding model.",
      )
      temperature: float = Field(default=0.0, description="Temperature for LLMs.")
      batch_size: int = Field(default=1, description="Batch size for calling LLMs.")
      texts_index_mmr_lambda: float = Field(
          default=1.0, description="Lambda for MMR in text index."
      )
      verbosity: int = Field(
          default=0,
          description=(
              "Integer verbosity level for logging (0-3). 3 = all LLM/Embeddings calls"
              " logged."
          ),
      )
      custom_context_serializer: AsyncContextSerializer | None = Field(
          default=None,
          description=(
              "Function to turn settings and contexts into an answer context str."
              " If not populated, the default context serializer will be used."
          ),
          exclude=True,
      )
  
      @model_validator(mode="after")
      def _update_temperature(self) -> Self:
          """Ensures temperature is 1 if the LLM requires it.
  
          o1 reasoning models only support temperature = 1.
          SEE: https://platform.openai.com/docs/guides/reasoning/quickstart
          """
          for model_prefix in ("o1", "gpt-5"):
              if self.llm.startswith(model_prefix) and self.temperature != 1:
                  warnings.warn(
                      f"When dealing with OpenAI {model_prefix} models,"
                      " the temperature must be set to 1,"
                      f" so the specified temperature {self.temperature}"
                      " has been overridden to 1.",
                      category=UserWarning,
                      stacklevel=2,
                  )
                  self.temperature = 1
          return self
  
      @computed_field  # type: ignore[prop-decorator]
      @property
      def md5(self) -> str:
          return hexdigest(self.model_dump_json(exclude={"md5"}))
  
      answer: AnswerSettings = Field(default_factory=AnswerSettings)
      parsing: ParsingSettings = Field(default_factory=ParsingSettings)
      prompts: PromptSettings = Field(default_factory=PromptSettings)
      agent: AgentSettings = Field(default_factory=AgentSettings)
  
      def get_index_name(self) -> str:
          """Get programmatically generated index name.
  
          This index is where parsings are stored based on parsing/embedding strategy.
          """
          if isinstance(self.agent.index.paper_directory, pathlib.Path):
              # Here we use an absolute path so that where the user locally
              # uses '.', two different folders will make different indexes
              first_segment: str = str(self.agent.index.paper_directory.absolute())
          else:
              first_segment = str(self.agent.index.paper_directory)
          segments = [
              first_segment,
              str(self.agent.index.use_absolute_paper_directory),
              self.embedding,
              str(self.parsing.parse_pdf),  # Don't use __name__ as lambda wouldn't differ
              str(self.parsing.reader_config["chunk_chars"]),
              str(self.parsing.reader_config["overlap"]),
              str(self.parsing.reader_config.get("full_page", False)),
              str(self.parsing.multimodal),
          ]
          return f"pqa_index_{hexdigest('|'.join(segments))}"
  
      @classmethod
      def from_name(
          cls, config_name: str = "default", cli_source: CliSettingsSource | None = None
      ) -> "Settings":
          json_path: pathlib.Path | None = None
  
          # quick exit for default settings
          if config_name == "default":
              if not cli_source:
                  raise NotImplementedError(
                      f"For config_name {config_name!r}, we require cli_source."
                  )
              return Settings(_cli_settings_source=cli_source(args=True))
  
          user_config_path = pqa_directory("settings") / f"{config_name}.json"
          pkg_config_path = (
  