# LLM Configuration (ChatLiteLLM unified)

llm_model: "gpt-4o-mini" # Model name routed via LiteLLM

max_concurrent_tasks: 1 # Increased default slightly

# Enabled Data Sources - these are the services the researcher will attempt to use

- "google_news" # Example: commented out to disable

# API Key Configuration (Fallbacks if not set in environment variables)

# Environment variables (e.g., API__OPENAI_API_KEY) take precedence.

# These values are used if the corresponding environment variable is not found.

# For sensitive keys, it's strongly recommended to use environment variables or a secrets manager.

level: "DEBUG" # DEBUG, INFO, WARNING, ERROR, CRITICAL

# Rate Limiting Configuration (requests_per_minute, concurrent_requests)

# These are examples; adjust based on your API plan limits and expected usage.

openai: # Example for a specific OpenAI model tier

requests_per_minute: 60 # Adjust based on your tier (e.g., Tier 1 might be 500 RPM for gpt-4o-mini)

concurrent_requests: 3  # Adjust based on your tier (e.g., Tier 1 might be 5 RPD for gpt-4o-mini)

#   requests_per_minute: 6000 # Serper often has high free tier limits

#   requests_per_minute: 100 # Check current Bing API limits

default: # Fallback for any service not explicitly listed

cleanup_interval_hours: 6  # Run cleanup every 6 hours

min_severity: "WARNING"  # Options: INFO, WARNING, ERROR

collect_cache_warnings: false  # Set to true to collect cache-related warnings

base_delay: 2.0  # Base delay in seconds

max_delay: 60.0  # Maximum delay in seconds

backoff_factor: 2.0  # Exponential backoff multiplier

jitter: true  # Add random jitter to delays

enabled: true  # Master switch for observability features

# Keys can also be here as fallbacks if not in ENV, but ENV is preferred

session_id_pattern: "company_research_{timestamp}"  # Pattern for session IDs

trace_sample_rate: 1.0  # Sample rate for traces (0.0 to 1.0)

debug_mode: false  # Enable debug mode for Langfuse client

# Service Endpoints (Example - not currently used by core researcher but demonstrates structure)

#   auth_service: "http://localhost:8001/api"

#   data_service: "http://localhost:8002/v1"

# ROTATION_SALT: ""  # Optional: prefer APP_ROTATION_SALT env; if absent, SessionManager generates ephemeral in-memory

# External Research Service Configuration

# Mirrors ExternalResearchConfig in code. If base_url is blank or enabled is false, the

# external research provider is skipped. Set base_url to the running FastAPI service

enabled: true               # Master switch

http_timeout_seconds: 60    # Per HTTP request timeout

poll_interval_seconds: 10   # Polling interval while waiting for completion

max_minutes: 5              # Max minutes to wait before timing out

ignore_cache_default: false # Submit with ignore_cache flag

generate_report_default: true  # Ask external service to generate report.md

refresh_sources_default: [] # List of source identifiers to force refresh (e.g. ["google_news"])

from pydantic_settings import BaseSettings, SettingsConfigDict

firecrawl_api_key: Optional[SecretStr] = Field(

browserless_api_key: Optional[SecretStr] = Field(

openai_api_key: Optional[SecretStr] = Field(None, description="OpenAI API Key")

anthropic_api_key: Optional[SecretStr] = Field(

google_ai_api_key: Optional[SecretStr] = Field(

serper_api_key: Optional[SecretStr] = Field(None, description="Serper API Key")

tavily_api_key: Optional[SecretStr] = Field(None, description="Tavily API Key")

bing_api_key: Optional[SecretStr] = Field(None, description="Bing API Key")

# Added missing API keys for centralized configuration

langfuse_public_key: Optional[SecretStr] = Field(

langfuse_secret_key: Optional[SecretStr] = Field(

langfuse_host: Optional[SecretStr] = Field(None, description="Langfuse Host URL")

companies_house_api_key: Optional[SecretStr] = Field(

None, description="UK Companies House API Key"

browserless_bql_host: Optional[SecretStr] = Field(

None, description="Browserless BQL Host URL"

linkedin_cookies_file: Optional[str] = Field(

None, description="LinkedIn Cookies File Path"

# This allows keys to be optional if not set via ENV or config.yaml

# Specific tool usage should handle missing keys gracefully

# --- Helper utilities to safely unwrap secrets ---

def _unwrap(self, value):  # pragma: no cover - trivial

"""Return plain string value for a given API key field name (or None)."""

raise AttributeError(f"Unknown API key field: {key}")

def as_plain_dict(self) -> Dict[str, str]:

"""Return a dict of all non-empty api keys as plain strings (unwrapped)."""

for field_name in self.model_fields:  # type: ignore[attr-defined]

description="Logging level (e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL)",

verbose: bool = Field(False, description="Enable verbose logging output")

valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

f"Logging level must be one of {valid_levels}, got {value}"

"""Rate limit entry for a specific technology"""

..., gt=0, description="Number of requests allowed per minute"

..., gt=0, description="Number of concurrent requests allowed"

"""Rate Limiting Configuration per technology type"""

# Using Optional for each as they might not all be defined

firecrawl: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Firecrawl"

browserless: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Browserless"

openai: Optional[RateLimitEntry] = Field(None, description="Rate limits for OpenAI")

anthropic: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Anthropic"

google_ai: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Google AI"

serper: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Serper API"

tavily: Optional[RateLimitEntry] = Field(

None, description="Rate limits for Tavily API"

bing: Optional[RateLimitEntry] = Field(None, description="Rate limits for Bing API")

description="Default rate limits if a specific technology is not configured",

# Additional timeout settings for Browserless

browserless_timeout_seconds: int = Field(

30, ge=1, description="Timeout for Browserless operations in seconds"

enabled: bool = Field(True, description="Enable or disable caching")

"cache/company_research.db", description="Path to the SQLite cache database"

24, gt=0, description="TTL for raw data cache in hours"

168, gt=0, description="TTL for summary cache in hours"  # 7 days

True, description="Enable automatic cleanup of expired cache entries"

6, gt=0, description="Interval between automatic cache cleanups in hours"

enabled: bool = Field(True, description="Enable or disable warning system")

description="Minimum severity level to collect warnings (INFO, WARNING, ERROR)",

True, description="Log warnings to the application logger"

False, description="Collect warnings for cache-related issues"

valid_levels = ["INFO", "WARNING", "ERROR"]

f"Warning severity must be one of {valid_levels}, got {value}"

"""Retry service configuration for a specific service."""

max_attempts: int = Field(3, gt=0, description="Maximum number of retry attempts")

base_delay: float = Field(1.0, ge=0.0, description="Base delay in seconds")

max_delay: float = Field(60.0, ge=0.0, description="Maximum delay in seconds")

2.0, ge=1.0, description="Exponential backoff multiplier"

def validate_max_delay(cls, value, info):

"""Ensure max_delay is >= base_delay."""

if "base_delay" in info.data and value < info.data["base_delay"]:

raise ValueError("max_delay must be >= base_delay")

enabled: bool = Field(True, description="Enable or disable retry system")

3, gt=0, description="Default maximum number of retry attempts"

base_delay: float = Field(1.0, ge=0.0, description="Default base delay in seconds")

60.0, ge=0.0, description="Default maximum delay in seconds"

2.0, ge=1.0, description="Default exponential backoff multiplier"

jitter: bool = Field(True, description="Add random jitter to delays")

services: Dict[str, RetryServiceConfig] = Field(

default_factory=dict, description="Service-specific retry configurations"

session_id_pattern: Optional[str] = Field(

None, description="Pattern for session IDs (e.g., 'user_{user_id}')"

1.0, ge=0.0, le=1.0, description="Sample rate for traces (0.0 to 1.0)"

debug_mode: bool = Field(False, description="Enable debug mode for Langfuse client")

enabled: bool = Field(True, description="Enable or disable observability features")

bot_token: str = Field("", description="Telegram bot token")

allowed_user_ids: List[int] = Field(default_factory=list, description="Allowed Telegram user IDs")

max_text_preview_chars: int = Field(1500, ge=1, description="Max inline text preview length in messages")

send_as_document_threshold_chars: int = Field(2000, ge=1, description="Send large texts as .txt document above this length")

# Accept comma-separated strings, lists of strings/ints, or None

parts = [p.strip() for p in v.split(",") if p.strip()]

"""Configuration for external company research service integration.

Loaded from nested config or environment variables with prefix EXT_RESEARCH_.

(double underscore because env_nested_delimiter='__' at AppConfig level).

env_prefix="EXT_RESEARCH__",  # works with AppConfig env nesting

enabled: bool = Field(True, description="Enable external research provider")

base_url: str = Field("", description="Base URL of external research API (http://host:port)")

api_key: Optional[SecretStr] = Field(None, description="Optional API key for external research API")

http_timeout_seconds: int = Field(30, ge=1, description="Per-request HTTP timeout (seconds)")

poll_interval_seconds: int = Field(10, ge=1, description="Polling interval while waiting for task completion")

max_minutes: int = Field(5, ge=1, description="Maximum minutes to wait for a research task")

ignore_cache_default: bool = Field(False, description="Default ignore_cache flag when submitting research")

generate_report_default: bool = Field(True, description="Generate textual report by default")

refresh_sources_default: List[str] = Field(default_factory=list, description="Default refresh_sources list")

def unwrap_api_key(self) -> Optional[str]:  # pragma: no cover - trivial

if self.api_key and hasattr(self.api_key, "get_secret_value"):

description="Directory containing prompt YAML files (relative to project root or absolute path).",

env_file=".env",  # Example: load from .env file if present

# secrets_dir can be used if you store secrets in files

default_factory=ApiKeysConfig, description="API Key configurations"

default_factory=RateLimitsConfig, description="Rate limit configurations"

default_factory=WarningConfig, description="Warning system configurations"

default_factory=RetryConfig, description="Retry system configurations"

observability: ObservabilityConfig = Field(

default_factory=TelegramConfig, description="Telegram bot configuration"

external_research: ExternalResearchConfig = Field(

default_factory=ExternalResearchConfig, description="External research service configuration"

"CompanyResearcher", description="Name of the application"

debug_mode: bool = Field(False, description="Enable debug mode for the application")

# Configurations migrated from old config.yaml

llm_temperature: float = Field(0.7, description="Temperature for LLM sampling")

llm_model: str = Field("openai/gpt-4o-mini", description="Default LLM model (used with ChatLiteLLM)")

1, gt=0, description="Maximum number of concurrent data source processing tasks"

enabled_data_sources: List[str] = Field(

description="List of enabled data sources for company research",

service_endpoints: Dict[str, HttpUrl] = Field(

description="Endpoints for various internal services",

# Session storage configuration (no side effects; directories are created lazily by SessionManager)

description="Base data root directory for app state and sessions",

description="Path to the SQLite session metadata database",

description="Directory for cross-process session lock files",

7, description="Retention days for sessions (informational; cleanup handled elsewhere)"

10, description="Grace period in minutes before removing empty materialized session dirs"

description="Optional rotation salt. If not set, SessionManager reads APP_ROTATION_SALT from env or generates ephemeral salt in-memory.",

True, description="Whether to persist session metadata by default on read-only interactions"

# Getters for lower_snake_case access to maintain compatibility with legacy call sites

def rotation_salt(self) -> Optional[str]:

def persist_metadata_default(self) -> bool:

def check_at_least_one_api_key(cls, v: ApiKeysConfig):

# Actual validation for specific keys should be handled by the tools using them.

# For example, if a tool requires OpenAI, it should check AppConfig.api_keys.openai_api_key.

# This example checks if *any* key is set, which might not be universally useful.

# Consider if this top-level validation for "any key" is truly needed or if

# individual components should be responsible for checking the specific keys they require.

#     # logger.warning("No API keys are configured. Some functionalities may be limited.")

#     pass # Allowing no API keys to be set initially

# Example of how to load and validate the configuration

# This part is for testing and demonstration, not for direct use in the application's main flow

# The load_config() function in __init__.py will be the primary way to get the config.

print("Attempting to load configuration for demonstration...")

# Create a dummy .env file for testing BaseSettings

with open(".env_test_app_config", "w", encoding="utf-8") as f:

env_prefix="APPCONFIG__",  # Prefix for top-level AppConfig fields

# api_keys field is inherited from AppConfig.

# ApiKeysConfig itself defines its env_prefix="API_"

print("\n--- Loading with .env_test_app_config ---")

print(f"Application Name (from env): {test_config_env.application_name}")

f"Logging Level (from env): {getattr(test_config_env.logging, 'level', None)}"

getattr(test_config_env, "api_keys", None), "openai_api_key", None

f"OpenAI API Key (from env via ApiKeysConfig prefix): {OPENAI_KEY.get_secret_value() if OPENAI_KEY else 'Not set'}"

getattr(test_config_env, "api_keys", None), "firecrawl_api_key", None

f"Firecrawl API Key (from env via AppConfig prefix): {FIRECRAWL_KEY.get_secret_value() if FIRECRAWL_KEY else 'Not set'}"

# Test with some direct data (simulating YAML load)

print("\n--- Loading with direct data (simulating YAML) ---")

"logging": {"level": "DEBUG", "verbose": True},

"openai": {"requests_per_minute": 50, "concurrent_requests": 3},

"default": {"requests_per_minute": 30, "concurrent_requests": 2},

OPENAI_KEY_DATA = getattr(app_conf_from_data.api_keys, "openai_api_key", None)

f"OpenAI Key: {OPENAI_KEY_DATA.get_secret_value() if OPENAI_KEY_DATA else 'Not set'}"

f"Firecrawl Key: {FIRECRAWL_KEY_DATA.get_secret_value() if FIRECRAWL_KEY_DATA else 'Not set'}"

print(f"Logging Level: {getattr(app_conf_from_data.logging, 'level', None)}")

f"Logging Verbose: {getattr(app_conf_from_data.logging, 'verbose', None)}"

OPENAI_LIMITS = getattr(app_conf_from_data.rate_limits, "openai", None)

"\n--- Testing missing required field (if any were defined as strictly required without defaults) ---"

# Example: If 'application_name' had no default and was not in data_from_yaml

#     del invalid_data["application_name"] # Assuming application_name is required

#     print(f"Successfully caught missing required field: {e}")

print("\n--- Testing API Key validation (no keys set) ---")

# app_conf_no_keys = AppConfig(**empty_api_keys_data)

# print(f"App Name (No Keys): {app_conf_no_keys.application_name}")

# print("Validation for 'at_least_one_api_key' passed as it's currently a soft warning.")

print(f"An error occurred during configuration loading demonstration: {e}")

from langchain_litellm import ChatLiteLLM

from job_seeker_app.config import load_config

from job_seeker_app.core.data_parser import DataParser

from job_seeker_app.core.llm_functions import LLMFunctionsManager

from job_seeker_app.core.task_dispatcher import TaskDispatcher

from job_seeker_app.core.context_builder import ContextBuilder

from job_seeker_app.core.research_provider_manager import ResearchProviderManager

from job_seeker_app.core.session_manager import SessionManager

from job_seeker_app.core.user_manager import UserManager

from job_seeker_app.utils.logger import logger

from job_seeker_app.utils.secrets import reveal, mask

"""Coordinate session, parsing, research, and LLM operations."""

# Firecrawl API key may be a SecretStr; unwrap safely before passing to DataParser

print("Firecrawl key detected (masked):", mask(firecrawl_key_value))

print("No Firecrawl API key configured; related features will be disabled.")

firecrawl_api_key=str(firecrawl_key_value) if firecrawl_key_value else None

self.task_dispatcher = TaskDispatcher(self.llm_functions, self.session_manager)

self.research_manager = ResearchProviderManager(self.session_manager, self.config)

# User management (simple email-based pseudo-auth)

self.current_user_email = os.getenv("DEV_USER_EMAIL", "guest@example.com")

"""Set current active user (simulated auth)."""

logger.info(f"Current user set: {self.current_user_email} ({self.current_user_id})")

def apply_user_defaults_to_session(self, session_id: str):

"""If user has default resources, copy them into session if not present."""

for resource_key in ["honest_resume", "base_resume"]:

default = self.user_manager.load_default_resource(self.current_user_id, resource_key)

if target_filename not in existing_files:

logger.info(f"Applied default {resource_key} to session {session_id}")

prompt_dir = os.path.join(os.path.dirname(__file__), '..', 'prompts')

with open(os.path.join(prompt_dir, filename), 'r', encoding='utf-8') as f:

prompts[filename.replace('.yaml', '')] = yaml.safe_load(f)

except Exception as exc:  # pragma: no cover

logger.error(f"Failed loading prompt {filename}: {exc}")

def run_task(self, task_name, user_data, session_id=None):

"""Register the file backing an artifact for later refinement."""

return self.task_dispatcher.register_artifact(artifact_id, filename, session_id)

"""Create an artifact/chat for an already-generated deliverable file."""

"""Delegate artifact refinement conversation to TaskDispatcher."""

def _strip_code_fences(text: str) -> str:

# Remove first line fence and last fence

if lines and lines[-1].strip().startswith("```"):

def get_session_data(self, session_id: str | None = None):

"""Return deserialized session data for the given or current session."""

"""Initialize unified ChatLiteLLM with optional Langfuse callbacks."""

model = self.config.llm_model or os.getenv("MAIN_MODEL", "gpt-4o-mini")

if lf_public_key and lf_secret_key and lf_host:

from langfuse.langchain import CallbackHandler

logger.info("Langfuse tracer initialized for CoreOrchestrator LLM.")

except Exception as e:  # pragma: no cover

logger.warning(f"Failed to initialize Langfuse tracer: {e}")

# Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)

return ChatLiteLLM(model=model, temperature=temperature, callbacks=callbacks or None)

def initialize_session(self, session_id: str) -> Dict[str, Any]:

Initializes a new session by creating its directory.

"message": f"Session '{session_id}' initialized.",

logger.error(f"Failed to initialize session '{session_id}': {e}")

"message": f"Failed to initialize session: {e}",

"""Deprecated: handled by ResearchProviderManager."""

return self.research_manager.run_deep_search_company(session_id, company, prompt)

Processes uploaded files, parses them, and saves to session.

Returns a structured response with parsed content and file info.

or any(sep in original_filename for sep in ("/", "\\"))

if ext not in DataParser.SUPPORTED_FILE_TYPES:

raise ValueError(f"Unsupported file type: {ext}")

# Smart file naming: resume, job_description, etc.

# If caller tells us what resource this file represents, honor it for canonical naming

canonical_name = self.context_builder._canonicalize_filename(original_filename, ext)

parsed_content = self.data_parser.parse_file_content(file_stream, ext)

"message": f"File '{original_filename}' uploaded and parsed.",

logger.error(f"File upload failed for session '{session_id}': {e}")

def save_user_default(self, resource_key: str, content: str) -> Dict[str, Any]:

except Exception as e:  # pragma: no cover

return {"status": "error", "message": f"Failed saving default {resource_key}: {e}"}

def load_user_default(self, resource_key: str) -> Dict[str, Any]:

content = self.user_manager.load_default_resource(self.current_user_id, resource_key)

return {"status": "missing", "message": f"No default {resource_key} set."}

return {"status": "success", "content": content}

return {"status": "error", "message": f"Failed loading default {resource_key}: {e}"}

def list_user_sessions(self) -> Dict[str, Any]:

return {"status": "success", "sessions": sessions}

return {"status": "error", "message": str(e)}

def rename_session(self, session_id: str, new_name: str) -> Dict[str, Any]:

return {"status": "success", "message": "Session renamed."}

return {"status": "error", "message": f"Failed to rename session: {e}"}

Scrapes content from a URL for a given resource type and saves it to the session.

Returns a structured response with scraped content and file info.

# Use the resource_type to create a canonical filename

"message": f"{resource_type.replace('_', ' ').title()} scraped from URL.",

logger.error(f"URL processing failed for session '{session_id}': {e}")

"message": f"URL processing failed: {e}",

def _prepare_llm_context(self, session_id: str) -> Dict[str, Any]:

"""Delegate LLM context preparation to ContextBuilder."""

def get_session_status(self, session_id: str) -> Dict[str, Any]:

Gets the status of resources and workflows for a given session.

if context.get(resource_key) != "Not provided":

if context.get("conversation_chain") != "Not provided":

"cl": {"title": "Generate Cover Letter", "outputs": ["cover_letter.md"]},

"jdj": {"title": "Extract Job Data", "outputs": ["job_data.json"]},

"erd": {"title": "Email Response Draft", "outputs": ["email_response_draft.md"]},

"lrd": {"title": "LinkedIn Response Draft", "outputs": ["linkedin_response_draft.md"]},

"tr": {"title": "Tailor Resume", "outputs": ["tailored_resume.json"]},

workflow_state = {key: "Pending" for key in workflow_steps}

for key, step in workflow_steps.items():

logger.error(f"Failed to get session status for session '{session_id}': {e}")

"message": f"Failed to get session status: {e}",

def _canonicalize_filename(self, original_filename: str, ext: str) -> str:

Maps uploaded filenames to canonical names for session storage.

if "job" in name and "description" in name:

if "company" in name and "json" in name:

if "company" in name and "summary" in name:

# fallback: use original filename sanitized

def _context_key_from_filename(self, fname: str) -> str:

Maps filenames to canonical context keys for LLM prompt context.

Uses substring matching for robust mapping.

Enhanced to support all variables required by all prompt templates.

if "my_json_resume" in lower or (lower.endswith(".json") and "resume" in lower):

if "career_consultant_opinion" in lower:

# Distinguish base vs honest resume explicitly

if lower.startswith("company_research_") or lower.startswith("deep_search_"):

if "company" in lower or "info" in lower:

if lower == "career_consultant_opinion.md":

# fallback: use filename without extension

# pylint: disable=no-member  # Disable false positives for Pydantic FieldInfo attributes
# pylint: disable=no-member  # Disable false positives for Pydantic FieldInfo attributes
import os
from datetime import datetime
from typing import Any, Dict, Optional
from uuid import uuid4

from langchain_litellm import ChatLiteLLM

from job_seeker_app.config import load_config
from job_seeker_app.core.data_parser import DataParser
from job_seeker_app.core.llm_functions import LLMFunctionsManager
from job_seeker_app.core.task_dispatcher import TaskDispatcher
from job_seeker_app.core.context_builder import ContextBuilder
from job_seeker_app.core.research_provider_manager import ResearchProviderManager
from job_seeker_app.core.session_manager import SessionManager
from job_seeker_app.core.user_manager import UserManager
from job_seeker_app.utils.logger import logger
from job_seeker_app.utils.secrets import reveal, mask
import traceback
import yaml

class CoreOrchestrator:
    """Coordinate session, parsing, research, and LLM operations."""

    def __init__(self) -> None:
        # Core components
        self.session_manager = SessionManager()
        self.config = load_config()
        self.llm_functions = LLMFunctionsManager(llm_instance=self._initialize_llm())

        # Firecrawl API key may be a SecretStr; unwrap safely before passing to DataParser
        firecrawl_key_value = reveal(self.config.api_keys.firecrawl_api_key)
        if firecrawl_key_value:
            print("Firecrawl key detected (masked):", mask(firecrawl_key_value))
        else:
            print("No Firecrawl API key configured; related features will be disabled.")
        self.data_parser = DataParser(
            firecrawl_api_key=str(firecrawl_key_value) if firecrawl_key_value else None
        )

        # New modular components
        self.context_builder = ContextBuilder(self.session_manager)
        self.task_dispatcher = TaskDispatcher(self.llm_functions, self.session_manager)
        self.research_manager = ResearchProviderManager(self.session_manager, self.config)

        # Load prompts
        self.prompts = self._load_prompts()

        # User management (simple email-based pseudo-auth)
        self.user_manager = UserManager()
        self.current_user_email = os.getenv("DEV_USER_EMAIL", "guest@example.com")
        self.current_user_id = self.user_manager.email_to_user_id(self.current_user_email)

    # ---- User context management ----
    def set_current_user(self, email: str):
        """Set current active user (simulated auth)."""
        if not email or "@" not in email:
            raise ValueError("Invalid email")
        self.current_user_email = email.strip().lower()
        self.current_user_id = self.user_manager.email_to_user_id(self.current_user_email)
        logger.info(f"Current user set: {self.current_user_email} ({self.current_user_id})")

    def apply_user_defaults_to_session(self, session_id: str):
        """If user has default resources, copy them into session if not present."""
        for resource_key in ["honest_resume", "base_resume"]:
            default = self.user_manager.load_default_resource(self.current_user_id, resource_key)
            if default:
                target_filename = f"{resource_key}.txt"
                # Don't overwrite if exists
                existing_files = self.session_manager.list_files(session_id)
                if target_filename not in existing_files:
                    self.session_manager.save_file(session_id, target_filename, default)
                    logger.info(f"Applied default {resource_key} to session {session_id}")

    def _load_prompts(self):
        prompts = {}
        prompt_dir = os.path.join(os.path.dirname(__file__), '..', 'prompts')
        for filename in os.listdir(prompt_dir):
            if filename.endswith('.yaml'):
                with open(os.path.join(prompt_dir, filename), 'r', encoding='utf-8') as f:
                    try:
                        prompts[filename.replace('.yaml', '')] = yaml.safe_load(f)
                    except Exception as exc:  # pragma: no cover
                        logger.error(f"Failed loading prompt {filename}: {exc}")
        return prompts

    def run_task(self, task_name, user_data, session_id=None):
        if session_id is None:
            session_id = self.session_manager.get_current_session_id()
        return self.task_dispatcher.run_task(task_name, user_data, session_id, self.context_builder)

    def register_artifact(self, artifact_id: str, filename: str, session_id: str | None = None):
        """Register the file backing an artifact for later refinement."""
        return self.task_dispatcher.register_artifact(artifact_id, filename, session_id)

    def create_artifact_for_existing_file(self, filename: str, session_id: str | None = None) -> dict:
        """Create an artifact/chat for an already-generated deliverable file."""
        return self.task_dispatcher.create_artifact_for_existing_file(filename, session_id)

    def continue_conversation(self, artifact_id, user_message_content, session_id: str | None = None):
        """Delegate artifact refinement conversation to TaskDispatcher."""
        return self.task_dispatcher.continue_conversation(artifact_id, user_message_content, session_id)

    @staticmethod
    def _strip_code_fences(text: str) -> str:
        if text.startswith("```"):
            # Remove first line fence and last fence
            lines = text.splitlines()
            # drop first line
            lines = lines[1:]
            # remove trailing fence if present
            if lines and lines[-1].strip().startswith("```"):
                lines = lines[:-1]
            return "\n".join(lines).strip()
        return text.strip()

    def get_session_data(self, session_id: str | None = None):
        """Return deserialized session data for the given or current session."""
        if session_id is None:
            session_id = self.session_manager.get_current_session_id()
        return self.session_manager.load_session(session_id)

    def parse_job_description(self, job_description_content):
        return self.data_parser.parse_job_description(job_description_content)

    def parse_resume(self, resume_content):
        return self.data_parser.parse_resume(resume_content)

    def _initialize_llm(self):
        """Initialize unified ChatLiteLLM with optional Langfuse callbacks."""
        model = self.config.llm_model or os.getenv("MAIN_MODEL", "gpt-4o-mini")
        temperature = self.config.llm_temperature
        callbacks = []
        # Langfuse tracing support
        lf_public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
        lf_secret_key = os.getenv("LANGFUSE_SECRET_KEY")
        lf_host = os.getenv("LANGFUSE_HOST")
        if lf_public_key and lf_secret_key and lf_host:
            try:
                from langfuse.langchain import CallbackHandler
                tracer = CallbackHandler(public_key=lf_public_key)
                callbacks.append(tracer)
                logger.info("Langfuse tracer initialized for CoreOrchestrator LLM.")
            except Exception as e:  # pragma: no cover
                logger.warning(f"Failed to initialize Langfuse tracer: {e}")
        # Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)
        return ChatLiteLLM(model=model, temperature=temperature, callbacks=callbacks or None)

    def initialize_session(self, session_id: str) -> Dict[str, Any]:
        """
        Initializes a new session by creating its directory.
        Returns a structured response.
        """
        try:
            session_path = self.session_manager.create_session(session_id)
            logger.info(f"Session initialized: {session_path}")
            # Link session to user metadata
            self.user_manager.write_session_meta(self.current_user_id, session_id)
            return {
                "status": "success",
                "message": f"Session '{session_id}' initialized.",
                "session_path": str(session_path),
            }
        except Exception as e:
            logger.error(f"Failed to initialize session '{session_id}': {e}")
            return {
                "status": "error",
                "message": f"Failed to initialize session: {e}",
            }

    # --- Research integrations ---
    def _ensure_external_research_client(self):
        """Deprecated: handled by ResearchProviderManager."""
        return None

    def _ensure_deep_search_client(self):
        """Deprecated: handled by ResearchProviderManager."""
        return None

    def run_external_company_research(self, session_id: str, company: str, poll_every: int = 10, max_minutes: int = 5) -> Dict[str, Any]:
        """Delegate to ResearchProviderManager."""
        return self.research_manager.run_external_company_research(session_id, company, poll_every=poll_every, max_minutes=max_minutes)

    def run_deep_search_company(self, session_id: str, company: str, prompt: str) -> Dict[str, Any]:
        """Delegate to ResearchProviderManager."""
        return self.research_manager.run_deep_search_company(session_id, company, prompt)

    def handle_file_upload(
        self, session_id: str, file_stream: bytes, original_filename: str, resource_key: str | None = None
    ) -> Dict[str, Any]:
        """
        Processes uploaded files, parses them, and saves to session.
        Returns a structured response with parsed content and file info.
        """
        try:
            # Validate session and filename
            # pylint: disable=protected-access
            session_id = self.session_manager._validate_session_id(session_id)
            if (
                not original_filename
                or any(sep in original_filename for sep in ("/", "\\"))
                or ".." in original_filename
            ):
                raise ValueError("Invalid filename.")
            ext = os.path.splitext(original_filename)[1].lower().strip(".")
            if ext not in DataParser.SUPPORTED_FILE_TYPES:
                raise ValueError(f"Unsupported file type: {ext}")

            # Smart file naming: resume, job_description, etc.
            # If caller tells us what resource this file represents, honor it for canonical naming
            if resource_key in {"honest_resume", "base_resume", "additional_context", "job_description", "company_info", "communication_history"}:
                canonical_name = f"{resource_key}.{ext}"
            else:
                canonical_name = self.context_builder._canonicalize_filename(original_filename, ext)
            self.session_manager.save_file(session_id, canonical_name, file_stream)

            # Parse file content for LLM context
            parsed_content = self.data_parser.parse_file_content(file_stream, ext)
            parsed_filename = f"{os.path.splitext(canonical_name)[0]}_parsed.md"
            self.session_manager.save_file(session_id, parsed_filename, parsed_content)

            logger.info(
                f"File '{original_filename}' uploaded and parsed as '{parsed_filename}' for session '{session_id}'."
            )

            return {
                "status": "success",
                "message": f"File '{original_filename}' uploaded and parsed.",
                "content": parsed_content,
                "filename": canonical_name,
                "parsed_filename": parsed_filename,
                "download_url": f"/sessions/{session_id}/{parsed_filename}",
            }
        except Exception as e:
            logger.error(f"File upload failed for session '{session_id}': {e}")
            logger.error(traceback.format_exc())
            return {
                "status": "error",
                "message": f"File upload failed: {e}",
            }

    # ---- User defaults API ----
    def save_user_default(self, resource_key: str, content: str) -> Dict[str, Any]:
        try:
            path = self.user_manager.save_default_resource(self.current_user_id, resource_key, content)
            return {"status": "success", "message": f"Default {resource_key} saved.", "path": str(path)}
        except Exception as e:  # pragma: no cover
            return {"status": "error", "message": f"Failed saving default {resource_key}: {e}"}

    def load_user_default(self, resource_key: str) -> Dict[str, Any]:
        try:
            content = self.user_manager.load_default_resource(self.current_user_id, resource_key)
            if content is None:
                return {"status": "missing", "message": f"No default {resource_key} set."}
            return {"status": "success", "content": content}
        except Exception as e:  # pragma: no cover
            return {"status": "error", "message": f"Failed loading default {resource_key}: {e}"}

    def list_user_sessions(self) -> Dict[str, Any]:
        try:
            sessions = self.user_manager.list_user_sessions(self.current_user_id)
            return {"status": "success", "sessions": sessions}
        except Exception as e:  # pragma: no cover
            return {"status": "error", "message": str(e)}

    def rename_session(self, session_id: str, new_name: str) -> Dict[str, Any]:
        try:
            self.user_manager.write_session_meta(self.current_user_id, session_id, display_name=new_name)
            return {"status": "success", "message": "Session renamed."}
        except Exception as e:
            return {"status": "error", "message": f"Failed to rename session: {e}"}

    def handle_url_input(self, session_id: str, url: str, resource_type: str) -> Dict[str, Any]:
        """
        Scrapes content from a URL for a given resource type and saves it to the session.
        Returns a structured response with scraped content and file info.
        """
        try:
            # pylint: disable=protected-access
            session_id = self.session_manager._validate_session_id(session_id)
            scraped_content = self.data_parser.scrape_web_content(url)

            # Use the resource_type to create a canonical filename
            filename = f"{resource_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"

            self.session_manager.save_file(session_id, filename, scraped_content)
            logger.info(
                f"URL '{url}' scraped for resource '{resource_type}' and saved as '{filename}' for session '{session_id}'."
            )
            return {
                "status": "success",
                "message": f"{resource_type.replace('_', ' ').title()} scraped from URL.",
                "content": scraped_content,
                "filename": filename,
            }
        except Exception as e:
            logger.error(f"URL processing failed for session '{session_id}': {e}")
            return {
                "status": "error",
                "message": f"URL processing failed: {e}",
            }

    def _prepare_llm_context(self, session_id: str) -> Dict[str, Any]:
        """Delegate LLM context preparation to ContextBuilder."""
        return self.context_builder.prepare_llm_context(session_id)

    def get_session_status(self, session_id: str) -> Dict[str, Any]:
        """
        Gets the status of resources and workflows for a given session.
        """
        try:
            # pylint: disable=protected-access
            session_id = self.session_manager._validate_session_id(session_id)
            context = self.context_builder.prepare_llm_context(session_id)
            files = self.session_manager.list_files(session_id)

            resources = {
                "honest_resume": {"status": "Missing"},
                "base_resume": {"status": "Missing"},
                "job_description": {"status": "Missing"},
                "company_info": {"status": "Missing"},
                "additional_context": {"status": "Missing"},
                "communication_history": {"status": "Missing"},
            }

            for resource_key in resources.keys():
                if context.get(resource_key) != "Not provided":
                    resources[resource_key]["status"] = "Provided"
            # Special-case: map conversation_chain context to communication_history resource status
            if context.get("conversation_chain") != "Not provided":
                resources["communication_history"]["status"] = "Provided"

            workflow_steps = {
                "cco": {"title": "Get Career Consultant Opinion", "outputs": ["career_consultant_opinion.md"]},
                "cl": {"title": "Generate Cover Letter", "outputs": ["cover_letter.md"]},
                "jdj": {"title": "Extract Job Data", "outputs": ["job_data.json"]},
                "erd": {"title": "Email Response Draft", "outputs": ["email_response_draft.md"]},
                "lrd": {"title": "LinkedIn Response Draft", "outputs": ["linkedin_response_draft.md"]},
                "tr": {"title": "Tailor Resume", "outputs": ["tailored_resume.json"]},
                "afq": {"title": "Application Form Questions", "outputs": ["application_form_questions.md"]},
            }

            workflow_state = {key: "Pending" for key in workflow_steps}
            for key, step in workflow_steps.items():
                for output_file in step["outputs"]:
                    if output_file in files:
                        workflow_state[key] = "Completed"
                        break

            return {
                "status": "success",
                "resources": resources,
                "workflows": workflow_state,
                "workflow_definitions": workflow_steps
            }
        except Exception as e:
            logger.error(f"Failed to get session status for session '{session_id}': {e}")
            return {
                "status": "error",
                "message": f"Failed to get session status: {e}",
            }

    # pylint: disable=too-many-return-statements
    def _canonicalize_filename(self, original_filename: str, ext: str) -> str:
        """
        Maps uploaded filenames to canonical names for session storage.
        """
        name = original_filename.lower()
        if "resume" in name:
            return "resume." + ext
        if "job" in name and "description" in name:
            return "job_description." + ext
        if "context" in name:
            return "additional_context." + ext
        if "company" in name and "json" in name:
            return "company.json"
        if "company" in name and "summary" in name:
            return "company_summary.md"
        if "json" in name and "resume" in name:
            return "my_json_resume.json"
        # fallback: use original filename sanitized
        return f"{uuid4().hex[:8]}_{os.path.basename(original_filename)}"

    # pylint: disable=too-many-return-statements
    def _context_key_from_filename(self, fname: str) -> str:
        """
        Maps filenames to canonical context keys for LLM prompt context.
        Uses substring matching for robust mapping.
        Enhanced to support all variables required by all prompt templates.
        """
        lower = fname.lower()
        # Substring-based mapping logic
        if "my_json_resume" in lower or (lower.endswith(".json") and "resume" in lower):
            return "my_json_resume"
        if "full_cv" in lower or (("cv" in lower or "full" in lower) and lower.endswith((".md", ".txt", ".pdf"))):
            return "full_cv"
        if "honest_life_context" in lower:
            return "honest_life_context"
        if "additional_info" in lower:
            return "additional_info"
        if "career_consultant_opinion" in lower:
            return "career_consultant_opinion"
        if "conversation_chain" in lower:
            return "conversation_chain"
        if "draft" in lower:
            return "draft"
        if "text" in lower:
            return "text"
        # Distinguish base vs honest resume explicitly
        if "base_resume" in lower:
            return "base_resume"
        if "resume" in lower:
            return "honest_resume"
        if "job" in lower or "spec" in lower:
            return "job_description"
        if lower.startswith("company_research_") or lower.startswith("deep_search_"):
            return "company_summary"
        if "company" in lower or "info" in lower:
            return "company_summary"
        if "context" in lower:
            return "additional_context"
        # Fallbacks for known outputs
        if lower == "company.json":
            return "company_json"
        if lower == "company_summary.md":
            return "company_summary"
        if lower == "my_json_resume.json":
            return "my_json_resume"
        if lower == "career_consultant_opinion.md":
            return "career_consultant_opinion"
        if lower == "cover_letter.md":
            return "cover_letter"
        if lower == "tailored_resume.json":
            return "tailored_resume"
        if lower == "job_data.json":
            return "job_data"
        # fallback: use filename without extension
        return os.path.splitext(fname)[0]

  146 146                 logger.warning(f"Failed to initialize Langfuse tracer: {e}")
  147 147         # Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)
- 148             return ChatLiteLLM(model=model, temperature=temperature, callbacks=callbacks or None)
+     148         return ChatLiteLLM(model=model, callbacks=callbacks or None)
  149 149
  150 150     def initialize_session(self, session_id: str) -> Dict[str, Any]:
  151 151         """

Prompt:
System
------
Your role as an AI assistant is to help developers complete their code tasks by assisting in editing specific sections of code marked by the <|code_to_edit|> and <|/code_to_edit|> tags, while adhering to Microsoft's content policies and avoiding the creation of content that violates copyrights.

You have access to the following information to help you make informed suggestions:

- recently_viewed_code_snippets: These are code snippets that the developer has recently looked at, which might provide context or examples relevant to the current task. They are listed from oldest to newest, with line numbers in the form #| to help you understand the edit diff history. It's possible these are entirely irrelevant to the developer's change.
- current_file_content: The content of the file the developer is currently working on, providing the broader context of the code. Line numbers in the form #| are included to help you understand the edit diff history.
- edit_diff_history: A record of changes made to the code, helping you understand the evolution of the code and the developer's intentions. These changes are listed from oldest to latest. It's possible a lot of old edit diff history is entirely irrelevant to the developer's change.
- area_around_code_to_edit: The context showing the code surrounding the section to be edited.
- cursor position marked as <|cursor|>: Indicates where the developer's cursor is currently located, which can be crucial for understanding what part of the code they are focusing on.

Your task is to predict and complete the changes the developer would have made next in the <|code_to_edit|> section. The developer may have stopped in the middle of typing. Your goal is to keep the developer on the path that you think they're following. Some examples include further implementing a class, method, or variable, or improving the quality of the code. Make sure the developer doesn't get distracted and ensure your suggestion is relevant. Consider what changes need to be made next, if any. If you think changes should be made, ask yourself if this is truly what needs to happen. If you are confident about it, then proceed with the changes.

# Steps

1. **Review Context**: Analyze the context from the resources provided, such as recently viewed snippets, edit history, surrounding code, and cursor location.
2. **Evaluate Current Code**: Determine if the current code within the tags requires any corrections or enhancements.
3. **Suggest Edits**: If changes are required, ensure they align with the developer's patterns and improve code quality.
4. **Maintain Consistency**: Ensure indentation and formatting follow the existing code style.

# Output Format

- Provide only the revised code within the tags. If no changes are necessary, simply return the original code from within the <|code_to_edit|> and <|/code_to_edit|> tags.
- There are line numbers in the form #| in the code displayed to you above, but these are just for your reference. Please do not include the numbers of the form #| in your response.
- Ensure that you do not output duplicate code that exists outside of these tags. The output should be the revised code that was between these tags and should not include the <|code_to_edit|> or <|/code_to_edit|> tags.

```
// Your revised code goes here
```

# Notes

- Apologize with "Sorry, I can't assist with that." for requests that may breach Microsoft content guidelines.
- Avoid undoing or reverting the developer's last change unless there are obvious typos or errors.
- Don't include the line numbers of the form #| in your response.
==================
User
------
```
<|recently_viewed_code_snippets|>
<|recently_viewed_code_snippet|>
code_snippet_file_path: /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml (truncated)
# Main Application Configuration
application_name: "JobSeekerTools"
debug_mode: true

# LLM Configuration (ChatLiteLLM unified)
llm_model: "gpt-5-mini" # Model name routed via LiteLLM

# Concurrency
max_concurrent_tasks: 1 # Increased default slightly

<|/recently_viewed_code_snippet|>

<|recently_viewed_code_snippet|>
code_snippet_file_path: /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/app_config.py (truncated)
from typing import Dict, List, Optional

from pydantic import BaseModel, Field, HttpUrl, SecretStr, field_validator, AliasChoices
from pydantic_settings import BaseSettings, SettingsConfigDict

class ApiKeysConfig(BaseSettings):
    """API Key Configuration"""

    model_config = SettingsConfigDict(
        env_prefix="API__",
        env_file=".env",
        extra="ignore",
        secrets_dir="/var/run/secrets",
    )

    firecrawl_api_key: Optional[SecretStr] = Field(
        None, description="Firecrawl API Key"
    )
    browserless_api_key: Optional[SecretStr] = Field(
        None, description="Browserless API Key"
    )
    openai_api_key: Optional[SecretStr] = Field(None, description="OpenAI API Key")
    anthropic_api_key: Optional[SecretStr] = Field(
        None, description="Anthropic API Key"
    )
    google_ai_api_key: Optional[SecretStr] = Field(
        None, description="Google AI API Key"
    )
    serper_api_key: Optional[SecretStr] = Field(None, description="Serper API Key")
    tavily_api_key: Optional[SecretStr] = Field(None, description="Tavily API Key")
    bing_api_key: Optional[SecretStr] = Field(None, description="Bing API Key")

    # Added missing API keys for centralized configuration
    langfuse_public_key: Optional[SecretStr] = Field(
        None, description="Langfuse Public Key"
    )
    langfuse_secret_key: Optional[SecretStr] = Field(
        None, description="Langfuse Secret Key"
    )
    langfuse_host: Optional[SecretStr] = Field(None, description="Langfuse Host URL")
    companies_house_api_key: Optional[SecretStr] = Field(
        None, description="UK Companies House API Key"
    )
    browserless_bql_host: Optional[SecretStr] = Field(
        None, description="Browserless BQL Host URL"
    )
    linkedin_cookies_file: Optional[str] = Field(
        None, description="LinkedIn Cookies File Path"
    )
    # Add other API keys as needed

    @field_validator("*", mode="before")
    @classmethod
    def ensure_api_key_present(cls, value):
        if value is None:
            # This allows keys to be optional if not set via ENV or config.yaml
            # Specific tool usage should handle missing keys gracefully
            pass
        return value

    # --- Helper utilities to safely unwrap secrets ---
    def _unwrap(self, value):  # pragma: no cover - trivial
        if hasattr(value, "get_secret_value"):
            try:
                return value.get_secret_value()
            except Exception:
                return None
        return value

    def get_plain(self, key: str):
        """Return plain string value for a given API key field name (or None)."""
        if not hasattr(self, key):
            raise AttributeError(f"Unknown API key field: {key}")
        return self._unwrap(getattr(self, key))

    def as_plain_dict(self) -> Dict[str, str]:
        """Return a dict of all non-empty api keys as plain strings (unwrapped)."""
        plain: Dict[str, str] = {}
        for field_name in self.model_fields:  # type: ignore[attr-defined]
            value = getattr(self, field_name, None)
            unwrapped = self._unwrap(value)
            if unwrapped:
                plain[field_name] = unwrapped
        return plain

class LoggingConfig(BaseModel):
    """Logging Configuration"""

    level: str = Field(
        "INFO",
        description="Logging level (e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL)",
    )
    verbose: bool = Field(False, description="Enable verbose logging output")
    format: str = Field(
        "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
        description="Loguru format string",
    )

    @field_validator("level")
    @classmethod
    def level_must_be_valid(cls, value):
        valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
        if value.upper() not in valid_levels:
            raise ValueError(
                f"Logging level must be one of {valid_levels}, got {value}"
            )
        return value.upper()

class RateLimitEntry(BaseModel):
    """Rate limit entry for a specific technology"""

    requests_per_minute: int = Field(
        ..., gt=0, description="Number of requests allowed per minute"
    )
    concurrent_requests: int = Field(
        ..., gt=0, description="Number of concurrent requests allowed"
    )

class RateLimitsConfig(BaseModel):
    """Rate Limiting Configuration per technology type"""

    # Using Optional for each as they might not all be defined
    firecrawl: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Firecrawl"
    )
    browserless: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Browserless"
    )
    openai: Optional[RateLimitEntry] = Field(None, description="Rate limits for OpenAI")
    anthropic: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Anthropic"
    )
    google_ai: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Google AI"
    )
    serper: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Serper API"
    )
    tavily: Optional[RateLimitEntry] = Field(
        None, description="Rate limits for Tavily API"
    )
    bing: Optional[RateLimitEntry] = Field(None, description="Rate limits for Bing API")
    default: RateLimitEntry = Field(
        RateLimitEntry(requests_per_minute=60, concurrent_requests=5),
        description="Default rate limits if a specific technology is not configured",
    )

    # Additional timeout settings for Browserless
    browserless_timeout_seconds: int = Field(
        30, ge=1, description="Timeout for Browserless operations in seconds"
    )
    # Add other technologies as needed

class CacheConfig(BaseModel):
    """Cache Configuration"""

    enabled: bool = Field(True, description="Enable or disable caching")
    database_path: str = Field(
        "cache/company_research.db", description="Path to the SQLite cache database"
    )
    raw_data_ttl_hours: int = Field(
        24, gt=0, description="TTL for raw data cache in hours"
    )
    summary_ttl_hours: int = Field(
        168, gt=0, description="TTL for summary cache in hours"  # 7 days
    )
    auto_cleanup_enabled: bool = Field(
        True, description="Enable automatic cleanup of expired cache entries"
    )
    cleanup_interval_hours: int = Field(
        6, gt=0, description="Interval between automatic cache cleanups in hours"
    )

class WarningConfig(BaseModel):
    """Warning System Configuration"""

    enabled: bool = Field(True, description="Enable or disable warning system")
    min_severity: str = Field(
        "INFO",
        description="Minimum severity level to collect warnings (INFO, WARNING, ERROR)",
    )
    log_warnings: bool = Field(
        True, description="Log warnings to the application logger"
    )
    collect_cache_warnings: bool = Field(
        False, description="Collect warnings for cache-related issues"
    )

    @field_validator("min_severity")
    @classmethod
    def validate_severity(cls, value):
        valid_levels = ["INFO", "WARNING", "ERROR"]
        if value.upper() not in valid_levels:
            raise ValueError(
<|/recently_viewed_code_snippet|>
<|/recently_viewed_code_snippets|>

<|current_file_content|>
current_file_path: job_seeker_tools/job_seeker_app/core/orchestrator.py
            session_id = self.session_manager.get_current_session_id()
        return self.task_dispatcher.run_task(task_name, user_data, session_id, self.context_builder)

    def register_artifact(self, artifact_id: str, filename: str, session_id: str | None = None):
        """Register the file backing an artifact for later refinement."""
        return self.task_dispatcher.register_artifact(artifact_id, filename, session_id)

    def create_artifact_for_existing_file(self, filename: str, session_id: str | None = None) -> dict:
        """Create an artifact/chat for an already-generated deliverable file."""
        return self.task_dispatcher.create_artifact_for_existing_file(filename, session_id)

    def continue_conversation(self, artifact_id, user_message_content, session_id: str | None = None):
        """Delegate artifact refinement conversation to TaskDispatcher."""
        return self.task_dispatcher.continue_conversation(artifact_id, user_message_content, session_id)

    @staticmethod
    def _strip_code_fences(text: str) -> str:
        if text.startswith("```"):
            # Remove first line fence and last fence
            lines = text.splitlines()
            # drop first line
            lines = lines[1:]
            # remove trailing fence if present
            if lines and lines[-1].strip().startswith("```"):
                lines = lines[:-1]
            return "\n".join(lines).strip()
        return text.strip()

    def get_session_data(self, session_id: str | None = None):
        """Return deserialized session data for the given or current session."""
        if session_id is None:
            session_id = self.session_manager.get_current_session_id()
        return self.session_manager.load_session(session_id)

    def parse_job_description(self, job_description_content):
        return self.data_parser.parse_job_description(job_description_content)

    def parse_resume(self, resume_content):
        return self.data_parser.parse_resume(resume_content)

    def _initialize_llm(self):
        """Initialize unified ChatLiteLLM with optional Langfuse callbacks."""
<|area_around_code_to_edit|>
        model = self.config.llm_model or os.getenv("MAIN_MODEL", "gpt-4o-mini")
        callbacks = []
        # Langfuse tracing support
        lf_public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
        lf_secret_key = os.getenv("LANGFUSE_SECRET_KEY")
        lf_host = os.getenv("LANGFUSE_HOST")
        if lf_public_key and lf_secret_key and lf_host:
            try:
                from langfuse.langchain import CallbackHandler
                tracer = CallbackHandler(public_key=lf_public_key)
                callbacks.append(tracer)
                logger.info("Langfuse tracer initialized for CoreOrchestrator LLM.")
            except Exception as e:  # pragma: no cover
<|code_to_edit|>
                logger.warning(f"Failed to initialize Langfuse tracer: {e}")
        # Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)
        return ChatLiteLLM(model=model, temperature=temperatur<|cursor|>e, callbacks=callbacks or None)

    def initialize_session(self, session_id: str) -> Dict[str, Any]:
        """
        Initializes a new session by creating its directory.
        Returns a structured response.
<|/code_to_edit|>
        """
        try:
            session_path = self.session_manager.create_session(session_id)
            logger.info(f"Session initialized: {session_path}")
            # Link session to user metadata
            self.user_manager.write_session_meta(self.current_user_id, session_id)
            return {
                "status": "success",
                "message": f"Session '{session_id}' initialized.",
                "session_path": str(session_path),
<|/area_around_code_to_edit|>
            }
        except Exception as e:
            logger.error(f"Failed to initialize session '{session_id}': {e}")
            return {
                "status": "error",
                "message": f"Failed to initialize session: {e}",
            }

    # --- Research integrations ---
    def _ensure_external_research_client(self):
        """Deprecated: handled by ResearchProviderManager."""
        return None

    def _ensure_deep_search_client(self):
        """Deprecated: handled by ResearchProviderManager."""
        return None

    def run_external_company_research(self, session_id: str, company: str, poll_every: int = 10, max_minutes: int = 5) -> Dict[str, Any]:
        """Delegate to ResearchProviderManager."""
        return self.research_manager.run_external_company_research(session_id, company, poll_every=poll_every, max_minutes=max_minutes)

    def run_deep_search_company(self, session_id: str, company: str, prompt: str) -> Dict[str, Any]:
        """Delegate to ResearchProviderManager."""
        return self.research_manager.run_deep_search_company(session_id, company, prompt)

    def handle_file_upload(
        self, session_id: str, file_stream: bytes, original_filename: str, resource_key: str | None = None
    ) -> Dict[str, Any]:
        """
        Processes uploaded files, parses them, and saves to session.
        Returns a structured response with parsed content and file info.
        """
        try:
            # Validate session and filename
            # pylint: disable=protected-access
            session_id = self.session_manager._validate_session_id(session_id)
            if (
                not original_filename
                or any(sep in original_filename for sep in ("/", "\\"))
                or ".." in original_filename
            ):
                raise ValueError("Invalid filename.")
            ext = os.path.splitext(original_filename)[1].lower().strip(".")
            if ext not in DataParser.SUPPORTED_FILE_TYPES:
                raise ValueError(f"Unsupported file type: {ext}")

            # Smart file naming: resume, job_description, etc.
<|/current_file_content|>

<|edit_diff_history|>
--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -30,0 +30,1 @@
+    container_name: job-seeker-company-researcher-api

--- /home/ubuntu/job_tel/.env
+++ /home/ubuntu/job_tel/.env
@@ -42,1 +42,1 @@
-EXT_RESEARCH_BASE_URL=http://localhost:8000
+EXT_RESEARCH_BASE_URL=http://job-seeker-company-researcher-api:8000

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -5,1 +5,0 @@
-    image: job-seeker-tools:telegram-bot

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -3,1 +3,1 @@
-      context: job-seeker-tools
+      context: job_seeker_tools

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -25,1 +25,1 @@
-      context: job-seeker-tools
+      context: company-researcher

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -32,0 +32,2 @@
+    env_file:
+      - .env

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -10,4 +10,0 @@
-      TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
-      # Optional access control lists (comma-separated user IDs)
-      TELEGRAM_ALLOWED_USER_IDS: ${TELEGRAM_ALLOWED_USER_IDS:-}
-      TELEGRAM_ADMIN_IDS: ${TELEGRAM_ADMIN_IDS:-}

--- /home/ubuntu/job_tel/docker-compose.yaml
+++ /home/ubuntu/job_tel/docker-compose.yaml
@@ -8,6 +8,0 @@
-    environment:
-      # Required - token must be set (either here or via .env)
-      # Ensure data paths inside container
-      APPCONFIG__DATA_ROOT: /app/data
-      APPCONFIG__SQLITE_PATH: /app/data/meta/sessions.db
-      APPCONFIG__LOCK_DIR: /app/data/locks

--- /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
+++ /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
@@ -165,1 +165,1 @@
-  base_url: "http://localhost:8000"               # Base URL of the external research service
+  base_url: "http://job-seeker-company-researcher-api:8000"               # Base URL of the external research service

--- /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
+++ /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
@@ -5,1 +5,1 @@
-llm_model: "gpt-4o-mini" # Model name routed via LiteLLM
+llm_model: "gpt-5-mini" # Model name routed via LiteLLM

--- /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
+++ /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/config.yaml
@@ -6,1 +6,0 @@
-llm_temperature: 0.7

--- /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/app_config.py
+++ /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/config/app_config.py
@@ -397,1 +397,0 @@
-    llm_temperature: float = Field(0.7, description="Temperature for LLM sampling")

--- /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/core/orchestrator.py
+++ /home/ubuntu/job_tel/job_seeker_tools/job_seeker_app/core/orchestrator.py
@@ -133,1 +133,0 @@
-        temperature = self.config.llm_temperature

<|/edit_diff_history|>

<|area_around_code_to_edit|>
        model = self.config.llm_model or os.getenv("MAIN_MODEL", "gpt-4o-mini")
        callbacks = []
        # Langfuse tracing support
        lf_public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
        lf_secret_key = os.getenv("LANGFUSE_SECRET_KEY")
        lf_host = os.getenv("LANGFUSE_HOST")
        if lf_public_key and lf_secret_key and lf_host:
            try:
                from langfuse.langchain import CallbackHandler
                tracer = CallbackHandler(public_key=lf_public_key)
                callbacks.append(tracer)
                logger.info("Langfuse tracer initialized for CoreOrchestrator LLM.")
            except Exception as e:  # pragma: no cover
<|code_to_edit|>
                logger.warning(f"Failed to initialize Langfuse tracer: {e}")
        # Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)
        return ChatLiteLLM(model=model, temperature=temperatur<|cursor|>e, callbacks=callbacks or None)

    def initialize_session(self, session_id: str) -> Dict[str, Any]:
        """
        Initializes a new session by creating its directory.
        Returns a structured response.
<|/code_to_edit|>
        """
        try:
            session_path = self.session_manager.create_session(session_id)
            logger.info(f"Session initialized: {session_path}")
            # Link session to user metadata
            self.user_manager.write_session_meta(self.current_user_id, session_id)
            return {
                "status": "success",
                "message": f"Session '{session_id}' initialized.",
                "session_path": str(session_path),
<|/area_around_code_to_edit|>
```

The developer was working on a section of code within the tags `code_to_edit` in the file located at `job_seeker_tools/job_seeker_app/core/orchestrator.py`. Using the given `recently_viewed_code_snippets`, `current_file_content`, `edit_diff_history`, `area_around_code_to_edit`, and the cursor position marked as `<|cursor|>`, please continue the developer's work. Update the `code_to_edit` section by predicting and completing the changes they would have made next. Provide the revised code that was between the `<|code_to_edit|>` and `<|/code_to_edit|>` tags with the following format, but do not include the tags themselves.
```
// Your revised code goes here
```
==================

Response:
```
                logger.warning(f"Failed to initialize Langfuse tracer: {e}")
        # Single path: ChatLiteLLM (LiteLLM selects provider via env / model name)
        return ChatLiteLLM(model=model, callbacks=callbacks or None)

    def initialize_session(self, session_id: str) -> Dict[str, Any]:
        """
        Initializes a new session by creating its directory.
        Returns a structured response.
```

- replaceRange:
   start: 7288
   endExclusive: 7381
  newText: "        return ChatLiteLLM(model=model, callbacks=callbacks or None)"

```
{
	"isAsyncCompletions": true,
	"isRevisedCacheStrategy": true,
	"isCacheTracksRejections": true,
	"isRecentlyShownCacheEnabled": false,
	"debounceUseCoreRequestTime": false
}
```

Line 0 emitted with latency 592.3178969994187 ms

Line 1 emitted with latency 616.5340500008315 ms

Line 2 emitted with latency 674.3745430000126 ms

Line 3 emitted with latency 690.1839509997517 ms

Line 4 emitted with latency 690.2021910008043 ms

pushing edit #0:\n[148,149)->["        return ChatLiteLLM(model=model, callbacks=callbacks or None)"]

Line 5 emitted with latency 1184.9659150000662 ms

Line 6 emitted with latency 1185.0202350001782 ms

Line 7 emitted with latency 1286.7972599994391 ms

Line 8 emitted with latency 1287.2199809998274 ms

Line 9 emitted with latency 1291.0832330007106 ms