File size: 11,368 Bytes
2bd66df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75ce203
bc32a44
2bd66df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6382b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75ce203
 
d6382b1
 
 
 
 
75ce203
 
 
 
 
d6382b1
75ce203
2bd66df
 
75ce203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19c6bad
 
 
 
 
 
bc32a44
19c6bad
 
 
 
 
75ce203
 
 
 
 
 
 
 
 
 
 
658d200
75ce203
 
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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""
Aurelius β€” central configuration.

All tunables live here so they can be adjusted (or env-overridden) without
touching the search/embedding/wiki/server logic. See CLAUDE.md and the
architect plan (Production Refactor + Algorithm + UI Pass, Β§2) for context.
"""

import os
from pathlib import Path

# ── Embedding model ──────────────────────────────────────────────────────
# Default is all-MiniLM-L6-v2: 80 MB / 384-dim, fits free-tier deployment
# (Render free web service) without OOMing or blowing the cold-start budget.
# Qwen/Qwen3-Embedding-0.6B (plan Β§3) is a better-quality drop-in if you're
# running on a host with more RAM β€” set AURELIUS_EMBED_MODEL to switch.
EMBED_MODEL_NAME    = os.getenv("AURELIUS_EMBED_MODEL", "all-MiniLM-L6-v2")
EMBED_DEVICE        = os.getenv("AURELIUS_EMBED_DEVICE", "cpu")
EMBED_BATCH_SIZE    = int(os.getenv("AURELIUS_EMBED_BATCH", "64"))   # smaller for 0.6B vs 128 for MiniLM

# ── Wikipedia ────────────────────────────────────────────────────────────
WIKI_API            = "https://en.wikipedia.org/w/api.php"
MAX_FETCH           = 500   # max links fetched from Wikipedia (paginated)
MAX_PAGES           = 6     # safety cap on pagination pages when hunting for a specific goal link
# Wikimedia's robot policy (https://w.wiki/4wJS) 403s requests whose
# User-Agent has no contact info β€” this was dropped during the module split
# and the bare "Aurelius-WikiNavigator/7.0" string started getting blocked.
WIKI_HEADERS = {
    "User-Agent": "Aurelius-WikiNavigator/7.0 (educational project; contact: murtaza.vali.ug25@plaksha.edu.in)",
    "Accept": "application/json",
}

# ── Search ───────────────────────────────────────────────────────────────
MAX_HOPS            = 100    # enforced as expansion-count cap, not path-length cap (see search.py)
TOP_DISPLAY         = 35    # max neighbours sent to frontend per step

# ── Scoring ──────────────────────────────────────────────────────────────
# Architect plan (Honest Bidirectional Meeting-Check Rewrite): the target's
# backlinks (depth-1) are now the actual goal zone, not just a score hint β€”
# see search.py's meeting check. Depth-2 frontier, its bonus, and the
# category bonus were removed: none of them could contribute a real,
# verifiable edge, and depth-2 specifically could not be stitched into a
# real path without bridge bookkeeping it never had. The stagnation/
# frontier-jump escape valve was removed entirely β€” it fabricated a
# came_from pointer with no corresponding Wikipedia link (see the Marcus
# Aurelius -> A* search algorithm incident), which is no longer needed now
# that the meeting check gives the search an honest way to converge.
PRUNE_TOP_K         = 15    # keep this many candidates per expansion after scoring
# NOTE: tried raising this to 0.22 as part of the Semantic Drift fix (see
# search.py docstring) on the theory that richer "{title}. {short_desc}"
# embeddings would make raw cosine trustworthy enough to support a higher
# floor. Verified live against Marcus Aurelius -> A* search algorithm: the
# best of Marcus Aurelius's 500 direct links scores only ~0.186 raw cosine
# to the (also-enriched) target embedding β€” there is no semantically close
# 1-hop neighbour for a niche CS topic from a Roman-emperor article, full
# stop, regardless of embedding quality. 0.22 killed the search at step 1
# (0/500 candidates survived). Kept at 0.15. The actual drift fix is the
# embedding enrichment improving *relative* ranking among survivors, plus
# DEPTH_TIEBREAK_EPSILON preventing runaway commitment to one irrelevant
# cluster β€” not an absolute floor, which hard multi-hop cases can't clear
# early on by construction.
PRUNE_FLOOR         = 0.15  # soft floor on raw cosine-to-target; frontier members exempt
PRUNE_MIN_SURVIVORS = 5     # ALWAYS keep at least this many top-ranked candidates per step,
                            # even if they fall below PRUNE_FLOOR β€” prevents the search from
                            # killing itself at step 1 for semantically distant pairs
                            # (Cleopatra β†’ Time complexity: 0/500 candidates cleared 0.15,
                            # search died immediately)
FRONTIER_D1_BONUS   = 0.30  # candidate is a direct backlink of the target (goal-zone member)

# Depth tie-breaker (Semantic Drift fix): heap priority is h + DEPTH_EPSILON*g
# instead of pure h. This is a deliberate, documented reversal of the prior
# "heap priority = h only, no g-cost" design (see CLAUDE.md) β€” pure-greedy
# let one noisy high-scoring title (e.g. a proper noun with no domain
# context) drag the search arbitrarily deep into its cluster with nothing
# to prefer a shallower, more recently-improving alternative. 0.01 is small
# enough that it only breaks ties/near-ties; it doesn't override a genuinely
# strong h advantage at any reasonable depth (60 hops -> max 0.6 penalty).
DEPTH_TIEBREAK_EPSILON = 0.01

# ── Active Backward Expansion (goal-zone depth-2) ───────────────────────
GOAL_ZONE_EXPAND_INTERVAL   = 10    # expand goal zone every N steps
GOAL_ZONE_EXPAND_BATCH      = 5     # d1 members to expand per interval
GOAL_ZONE_D2_BACKLINK_LIMIT = 100   # max backlinks fetched per d1 member
FRONTIER_D2_BONUS           = 0.15  # score bonus for d2 goal-zone members (half of d1's 0.30)

# ── Stagnation Detection / Cluster Escape ────────────────────────────────
STAGNATION_WINDOW       = 8     # sliding window size for h-improvement tracking
STAGNATION_DELTA        = 0.02  # min improvement over window to count as progress
PRUNE_TOP_K_STAGNANT    = 25    # widened beam when stagnant (vs 15 normal)
DIVERSITY_WEIGHT        = 0.10  # weight for centroid-distance diversity bonus

# ── Security / abuse limits ──────────────────────────────────────────────
# This backend is public and unauthenticated. None of the limits below
# change search behaviour for a normal user β€” they only bound what a single
# abusive client can make the server do (open WS handshakes, CPU-bound
# embedding, and Wikipedia API traffic on the shared free tier).
#
# ALLOWED_ORIGINS: browser Origins permitted to call the API (CORS for the
# REST endpoints + an explicit Origin check on the WebSocket handshake).
# Defaults cover the deployed Vercel frontend, localhost dev, and the
# "null" origin used when index.html is opened directly via file:// (the
# documented local workflow). Override in production with the env var, e.g.
# ALLOWED_ORIGINS="https://aurelius-psi.vercel.app". Non-browser clients
# (curl, scripts) send no Origin and are unaffected β€” Origin checks only
# defend against other websites' JS driving this backend from a user's browser.
ALLOWED_ORIGINS = [
    o.strip() for o in os.getenv(
        "ALLOWED_ORIGINS",
        "https://aurelius-psi.vercel.app,"
        "http://localhost:8000,http://127.0.0.1:8000,"
        "http://localhost:8081,http://127.0.0.1:8081,null",
    ).split(",") if o.strip()
]
MAX_QUERY_LEN           = int(os.getenv("AURELIUS_MAX_QUERY_LEN", "200"))   # reject absurd start/end strings
MAX_CONCURRENT_SEARCHES = int(os.getenv("AURELIUS_MAX_CONCURRENT", "4"))    # parallel searches the box will run
MAX_SEARCH_SECONDS      = int(os.getenv("AURELIUS_MAX_SEARCH_SECONDS", "300"))  # hard wall-clock cap per connection
# The two knobs above bound a single WS connection; the sliding-window rate
# limit below bounds total request volume per client IP across connections
# (a concurrency cap alone can't stop rapid connect/abandon loops).
RATE_LIMIT_WINDOW_S     = int(os.getenv("AURELIUS_RATE_WINDOW", "60"))
RATE_LIMIT_MAX_REQUESTS = int(os.getenv("AURELIUS_RATE_MAX", "30"))   # per IP per window

# ── Files / store ────────────────────────────────────────────────────────
_THIS_DIR           = Path(__file__).resolve().parent
SEED_FILE           = _THIS_DIR / "common_wiki_searches.txt"
# Persistent graph + vector store (SQLite; the interface is pgvector-shaped
# so a Postgres swap is config-level β€” see core/store.py).
AURELIUS_DB         = Path(os.getenv("AURELIUS_DB", str(_THIS_DIR / "data" / "aurelius.db")))

# ── Adapters ─────────────────────────────────────────────────────────────
# Contact email: the Wikimedia robot-policy lesson applies to every
# upstream API β€” identify yourself or get throttled. OpenAlex's "polite
# pool" is explicitly faster with a mailto.
CONTACT_EMAIL       = os.getenv("AURELIUS_CONTACT", "murtaza.vali.ug25@plaksha.edu.in")
OPENALEX_API        = "https://api.openalex.org"
NEWSAPI_KEY         = os.getenv("NEWSAPI_KEY", "")           # optional; GDELT fallback when absent
# News Intelligence scheduled refresh: minutes between pipeline runs.
# 0 (default) = off β€” refresh manually via /api/news/refresh or the CLI.
NEWS_REFRESH_MINUTES = int(os.getenv("NEWS_REFRESH_MINUTES", "0"))
HETIONET_URL        = ("https://github.com/hetio/hetionet/raw/main/"
                       "hetnet/json/hetionet-v1.0.json.bz2")

# ── Optional LLM layer (Google Gemini via Google AI Studio) ──────────────
# Purely additive: every AI feature is a lazy call made AFTER the fast
# non-LLM result is already on screen, and the whole app runs with no key.
# The key is a backend-only secret β€” never committed, never sent to the
# browser (only /api/llm/status's booleans are). See core/llm.py.
GEMINI_API_KEY      = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL        = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
GEMINI_RPM          = int(os.getenv("GEMINI_RPM", "12"))          # soft calls/min budget β€” stay under the free-tier limit
GEMINI_COOLDOWN_S   = int(os.getenv("GEMINI_COOLDOWN_S", "60"))   # pause after an upstream 429/5xx
GEMINI_TIMEOUT_S    = float(os.getenv("GEMINI_TIMEOUT_S", "12"))
GEMINI_MAX_CONCURRENCY = int(os.getenv("GEMINI_MAX_CONCURRENCY", "3"))

# ── Structural embeddings (node2vec β€” core/representation.py) ────────────
N2V_DIM             = int(os.getenv("AURELIUS_N2V_DIM", "64"))
N2V_WALKS_PER_NODE  = 10
N2V_WALK_LENGTH     = 20
N2V_WINDOW          = 5
N2V_EPOCHS          = 2
N2V_NEGATIVES       = 5
# Fusion weight for [text ; Ξ±Β·struct] β€” how much structure counts relative
# to text, per source. Text dominates for encyclopedic sources; structure
# carries sources whose node text is thin (tickers, genes, file paths).
FUSION_ALPHA = {
    "wikipedia": 0.3, "openalex": 0.5,
    "biomed": 0.8, "news": 0.5, "finance": 0.8,
}