A
File size: 4,379 Bytes
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
7575444
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7575444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
068faf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""Load config.yaml and initialize shared settings.

Loads, in order:
  1. config.yaml (model, paths, logging, auto_restart)
  2. .env (API keys for local dev; HF Spaces use Secrets instead)
  3. Three prompt markdown files (manager / main / perfect_answer)
  4. OpenAI client + HF API client

All callers downstream import their dependencies from this module, so
changing config / prompts is a single point of editing.
"""

import os
import platform
import threading
import yaml
from openai import OpenAI
from dotenv import load_dotenv
from huggingface_hub import HfApi
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent  # project root (one level up from core/)

with open(BASE_DIR / "config.yaml") as f:
    CONFIG = yaml.safe_load(f)

MODEL = CONFIG["model"]
PATHS = CONFIG["paths"]
REASONING_EFFORT = CONFIG.get("reasoning_effort", "medium")
VERBOSITY = CONFIG.get("verbosity", "medium")

# Load API key: .env for local dev, HF Secrets for Spaces.
env_path = BASE_DIR / PATHS["env_file"]
if env_path.exists():
    load_dotenv(env_path)

# OpenAI client pool for round-robin per-session key assignment.
# Loads OPENAI_KEY_01 .. OPENAI_KEY_10 from environment. Falls back to API_KEY
# (single client, no rotation) if no pool keys are present, so local dev still
# works with just one key in .env.
_pool_pairs = []
for _i in range(1, 11):
    _k = os.getenv(f"OPENAI_KEY_{_i:02d}")
    if _k:
        _pool_pairs.append((_i, _k))

KEY_POOL = []  # list of (key_idx, OpenAI client) tuples
if _pool_pairs:
    KEY_POOL = [(idx, OpenAI(api_key=k, max_retries=5)) for idx, k in _pool_pairs]
    print(f"[key_pool] loaded {len(KEY_POOL)} OPENAI_KEY_NN secrets for per-session rotation", flush=True)
else:
    _fallback = os.getenv("API_KEY")
    if _fallback:
        KEY_POOL = [(0, OpenAI(api_key=_fallback, max_retries=5))]
        print("[key_pool] no OPENAI_KEY_NN secrets found; using single API_KEY (no rotation)", flush=True)
    else:
        print("[key_pool] WARNING: no OpenAI key configured (no API_KEY or OPENAI_KEY_NN)", flush=True)

_counter = 0
_counter_lock = threading.Lock()


def get_next_client():
    """Round-robin OpenAI client picker. Call once per session at session start;
    pass the returned client through to all downstream calls in that session.

    Returns (key_idx, client). key_idx is 1-10 for pool keys, 0 for fallback API_KEY.
    Raises RuntimeError if no client is configured.
    """
    global _counter
    if not KEY_POOL:
        raise RuntimeError("no OpenAI client configured (set OPENAI_KEY_01..10 or API_KEY)")
    with _counter_lock:
        idx_in_pool = _counter % len(KEY_POOL)
        _counter += 1
    return KEY_POOL[idx_in_pool]


# Backward-compat: module-level `client` for code paths that have not yet been
# threaded with per-session clients. Equals the first pool entry.
client = KEY_POOL[0][1] if KEY_POOL else None

# HuggingFace logging setup. RUN_MODE env var picks the bucket
# (test|pilot|prod) appended to mode_prefix to form the final HF dataset path.
LOG_CONFIG = CONFIG.get("logging", {})
HF_TOKEN = os.getenv(LOG_CONFIG.get("hf_token_env", "HF_access"))
HF_DATASET = LOG_CONFIG.get("hf_dataset")
hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN and HF_DATASET else None

RUN_MODE = os.getenv("RUN_MODE", "test")
LOG_MODE_PREFIX = LOG_CONFIG.get("mode_prefix", "HOT/socratic")
LOG_PATH = f"{LOG_MODE_PREFIX}/{RUN_MODE}"
CONDITION = LOG_CONFIG.get("condition", "socratic")
SCHEMA_VERSION = LOG_CONFIG.get("schema_version", "1.1")
CASE_ID = LOG_CONFIG.get("case_id", "unknown")

# OS detection (still here for tests / utilities; main app no longer uses shell tool).
IS_WINDOWS = platform.system() == "Windows"


def _load_prompt(key, default_relpath):
    """Read a prompt markdown file. Returns "" if the file is missing so the
    app still boots (with reduced behaviour) rather than crashing."""
    relpath = PATHS.get(key, default_relpath)
    path = BASE_DIR / relpath
    if not path.exists():
        print(f"[config_loader] Warning: prompt file missing at {path}", flush=True)
        return ""
    return path.read_text(encoding="utf-8")


MANAGER_PROMPT = _load_prompt("manager_prompt", "prompts/manager_prompt.md")
MAIN_PROMPT = _load_prompt("main_prompt", "prompts/main_prompt.md")
PERFECT_ANSWER_PROMPT = _load_prompt("perfect_answer_prompt", "prompts/perfect_answer_prompt.md")