Repository navigation
Matching Engine
internal_ai_engine.py contains SpiralMatchEngine — the whole ranking brain of the bot. It is a
plain Python class with no framework dependencies, which means you can instantiate it in a script,
a test, or a notebook and ask it for matches without ever starting the bot.
Everything in this page is quoted from the source. Line references are to
internal_ai_engine.py unless stated otherwise.
class SpiralMatchEngine:
def __init__(self, profiles_path: Path = PROFILES_JSON): ...
def reload(self) -> None: ...
def match(self, user_info: Dict[str, Any]) -> List[Dict[str, Any]]: ...
# attribute: self.profiles -> List[Dict[str, Any]] (the indexed roster)| Member | What it does |
|---|---|
SpiralMatchEngine(profiles_path=...) |
Loads and indexes the counselor dataset. Defaults to consultants_profile_full.json next to the module. Also calls init_learning_db(), creating ai_knowledge.db and its concept_feedback table if they do not exist. |
.reload() |
Re-reads the JSON file and re-indexes it. Called by the /upload flow so a new spreadsheet takes effect without restarting the bot. |
.match(user_info) |
The main entry point. Returns a list of at most 2 ranked results. |
.profiles |
The indexed roster — each entry is the original profile dict plus the derived keys clean_name, gender, age_min, age_max, ability, location, search_text, active_specs. |
match() only reads these keys; anything else in the dict is ignored.
| Key | Type | Default when missing | Meaning |
|---|---|---|---|
age |
int |
0 |
User's age. 0 disables the age filter. |
preferred_gender |
str |
"فرقی ندارد" |
"خانم"/"زن", "آقا"/"مرد", or anything else = no filter. |
branch |
str |
"اهمیتی ندارد" |
"ظفر" or "خیابان ایران" activates the branch filter. |
location |
str |
falls back to branch
|
Branch preference used when counting free appointments. |
topic |
str |
"" |
Free-text main problem. |
expectation |
str |
"" |
Free-text expectation — concatenated with topic before concept extraction. |
ghq_scores |
dict |
None |
Output of calculate_ghq_scores(). Active only when the dict contains a "total" key. |
| Function | Purpose |
|---|---|
get_consultant_gender(name) |
Resolves gender from the MALE_COUNSELORS set (22 names). |
normalize_counselor_name(name) |
Canonical form of a name (see below). |
names_match(a, b) |
Fuzzy equality between two counselor names. |
names_match_token(x, y) |
Single-token comparison tolerating one differing character. |
parse_age_bounds_perfect(age_str) |
(min_age, max_age) from a free-text age-range string. |
get_free_appointments_count(name, branch_pref) |
Free slots for a counselor, optionally filtered by branch. |
record_learning_event(name, topic, ghq_level, success) |
Automatic learning from a recommendation. |
record_feedback_direct(name, concept, success) |
Manual learning from an admin rating. |
get_learning_weight(name, concept) |
Current weight for a (counselor × concept) pair. |
get_learning_stats() |
The 20 most recently updated learning rows, with their weights. |
init_learning_db() |
Creates the concept_feedback table. |
_concept_from_topic(topic) |
The concept detected in a single topic string. Returns the first matching concept in dictionary order, or None. |
_load_and_index_profiles() reads the JSON list and, for every profile, derives:
| Derived key | How it is computed |
|---|---|
clean_name |
name.strip() |
gender |
get_consultant_gender(name) → "آقا" if the name contains (or is contained by) an entry of MALE_COUNSELORS, else "خانم"
|
age_min / age_max
|
parse_age_bounds_perfect(profile["age_range"]) |
ability |
float(profile.get("ability", 2.0)), clamped to the set {1.0, 2.0, 3.0} (anything else becomes 2.0) |
location |
profile.get("location", "هر دو") |
active_specs |
The keys of specializations when it is a dict, else []
|
search_text |
A single lowercase string: education_experience + general_area + general_area_2 + specialization keys + specialization values + detailed_topics + license + notes
|
search_text is the reason the scoring pass is cheap: the multi-field keyword search happens once
at load time instead of once per candidate per request.
A note on the gender table.
MALE_COUNSELORSis a hard-coded list of 22 names, andget_consultant_gender()treats everything not in the list as female. That is a deliberate simplification of the original spreadsheet, but it means a new male counselor is classified as female until his name is added to the set. When you upload a new roster, check the list.
The filters live inside match() as _passes_hard_filters(p).
| # | Filter | Exact rule |
|---|---|---|
| 1 | Gender |
user_gender_pref in ["خانم", "زن"] → require p["gender"] == "خانم". user_gender_pref in ["آقا", "مرد"] → require p["gender"] == "آقا". |
| 2 | Branch | Active only when user_branch in ["ظفر", "خیابان ایران"]. A profile with location == "هر دو" always passes; otherwise user_branch must be a substring of p["location"]. |
| 3 | Age | Only when user_age > 0: require p["age_min"] - 1 <= user_age <= p["age_max"] + 1. |
| 4 | Legal guard | Unless the request triggered the حقوقی concept, drop a profile when "وکالت" in p["education_experience"] or p["active_specs"] == ["مشاوره حقوقی خانواده"]. |
candidates = [p for p in self.profiles if _passes_hard_filters(p)]
if not candidates: # Fallback pass 1 — drop the age filter
candidates = [p for p in self.profiles if _relax_age(p)]
if not candidates: # Fallback pass 2 — drop gender too
candidates = [p for p in self.profiles if not (...gender check...) and not (...legal guard...)]- Fallback 1 keeps gender, branch and the legal guard; only the age bounds are dropped.
- Fallback 2 keeps only the gender filter and the legal guard.
The final fallback guarantees the user always receives recommendations. The alternative — an empty shortlist — reads to the user as a broken bot, and the top-2 result card always explains why each counselor was suggested, so the loosening is visible rather than silent.
match() builds one lowercase string from the free text and scans it against the synonym table:
user_topic = (user_info.get("topic", "") + " " + user_info.get("expectation", "")).lower()
triggered_concepts = set()
for concept, keywords in EXPANDED_SYNONYMS.items():
if any(kw in user_topic for kw in keywords):
triggered_concepts.add(concept)Matching is a plain substring test (kw in user_topic), not tokenization — so multi-word
phrases like "مشکل با پدر" work, and a keyword like "غم" also matches inside longer words.
The dictionary holds 11 clinical concepts:
| Concept key | Keywords | Representative entries |
|---|---|---|
والد_فرزند |
25 | پدر، مادر، بابا، مامان، والد، والدین، فرزند، پسر، دختر، خانواده، تربیت، خانوادهدرمانی، والدگری، فرزندپروری، «تعارض با والدین»، «دعوا با پدر» |
ارتباط_تعارض |
24 | اختلاف، حرف، گفتگو، صحبت، مکالمه، تعارض، تعارضات، ارتباطی، بینفردی، «مهارتهای زندگی»، حل اختلاف، درک متقابل، ناسازگاری، کلکل، جروبحث، دعوا، روابط |
اضطراب |
14 | استرس، دلشوره، نگرانی، تپش قلب، پانیک، ترس، فوبیا، وحشت، بیقراری، اضطرابی، اضطراب، فوبیاها، بیقراری، anxiety
|
افسردگی |
16 | غم، بیحوصلگی، پوچی، ناامیدی، افسرده، انگیزه، خلق پایین، گریه، بیانرژی، خلق، خلقی، افسردگی، بی انگیزه، انگیزشی، سوگ، depression
|
وسواس |
8 | وسواسی، شستشو، فکر تکراری، چک کردن، نشخوار فکری، افکار مزاحم، وسواس، ocd
|
زوج_ازدواج |
15 | همسر، ازدواج، طلاق، خیانت، تعارض زناشویی، «دعوا با همسر»، روابط زوجین، پیش از ازدواج، نامزدی، سکس، جنسی، سکستراپی، زناشویی، زوج، واژینیسموس |
کودک |
17 | بچه، کودک، خردسال، «پرخاشگری کودک»، بیشفعالی، لجبازی، شب ادراری، «تغذیه کودک»، «خواب کودک»، مهد، اوتیسم، ناخن جویدن، دلبستگی، کودکان، پوشک، یادگیری، adhd
|
نوجوان_جوان |
14 | بلوغ، افت تحصیلی، کنکور، انتخاب رشته، هویت، «لجبازی نوجوان»، «ارتباط با جنس مخالف»، دوستیابی، نوجوانی، نوجوان، جوان، «۱۸ سال»، «18 سال»، کنکوری |
توسعه_فردی |
13 | عزت نفس، اعتماد به نفس، کوچینگ، هدفگذاری، رشد فردی، «مهارتهای زندگی»، سبک زندگی، خودشناسی، کمال گرایی، اهمال کاری، توسعه فردی، کمالگرایی، اهمالکاری |
حقوقی |
11 | مهریه، حضانت، نفقه، دادگاه، وکیل، طلاق قانونی، ارث، محجور، وکالت، قضائیه، حقوقی |
پزشکی_روانپزشکی |
8 | دارو، دارودرمانی، روانپزشک، «اعصاب و روان»، سایکوتیک، بیماری جسمی، «طب ایرانی»، دوقطبی |
Persian/Arabic variants are already in the dictionary. The list mixes Persian orthography
(ی, ک, ZWNJ) with Arabic-imported loanwords (استرس, سکستراپی, کوچینگ, روانپزشک,
دوقطبی, اضطراب) and the Latin/English clinical abbreviations anxiety, depression, ocd,
adhd — because users type all of these. The same table is reused for the special-case boosts, so
adding one synonym improves both detection and scoring.
Two concepts are special-cased in match():
is_legal_request = "حقوقی" in triggered_concepts
is_medical_request = "پزشکی_روانپزشکی" in triggered_conceptsis_legal_request unlocks the legal counselors that the hard filter would otherwise remove.
(is_medical_request is computed for clarity; the medical boost itself is driven by the concept
key.)
Every boost is added to a single score accumulator:
| Signal | Condition | Points |
|---|---|---|
| Keyword overlap | each user word (>2 chars) occurring in search_text
|
+12.0 each |
| "جنس مخالف" expertise |
"جنس مخالف" in the topic and in the profile |
+50 |
| Sexual health | topic ∩ {جنسی، سکس، سکستراپی، واژینیسموس، زناشویی} and profile ∩ {جنسی، سکس، سکستراپی، مامایی} | +45 |
| Academic guidance | topic ∩ {کنکور، انتخاب رشته، تحصیلی، برنامه ریزی} and profile ∩ same family | +45 |
| Learning disorders / ADHD | topic ∩ {یادگیری، املا، ریاضی، دیکته، نقص توجه، بیش فعالی، adhd} and profile ∩ {یادگیری، نقص توجه، بیش فعالی، adhd} | +45 |
| Legal |
حقوقی concept and profile ∩ {وکالت، حقوقی، دادگاه، حقوق خانواده} |
+80 |
| Psychiatry |
پزشکی_روانپزشکی concept and profile ∩ {روان پزشکان، اعصاب و روان، دارو، طب ایرانی} |
+80 |
| Parent–child |
والد_فرزند concept and profile ∩ {والد، فرزند، والدگری، خانواده} |
+50 |
| Communication |
ارتباط_تعارض concept and profile ∩ {بینفردی، بین فردی، روابط، ارتباط، تعارض، مهارت} |
+45 |
| Teen |
نوجوان_جوان concept or 12 ≤ age ≤ 19, profile ∩ {نوجوان، نوجوانی، جوان} |
+35 |
| Child |
کودک concept or 0 < age < 12, profile ∩ {کودک، کودکان، فرزندپوری، بازی} |
+40 |
| Couple |
زوج_ازدواج concept and profile ∩ {زوج، ازدواج، زناشویی، همسر} |
+45 |
| Depression |
افسردگی concept and profile ∩ {افسردگی، خلق، خلقی، سوگ، طرحواره} |
+40 |
| OCD |
وسواس concept and profile ∩ {وسواس، وسواسی، ocd} |
+45 |
| Anxiety |
اضطراب concept and profile ∩ {اضطراب، استرس، پانیک، فوبیا} |
+40 |
| Self-development |
توسعه_فردی concept and profile ∩ {توسعه فردی، عزت نفس، کوچینگ، کمال} |
+35 |
| High risk + psychiatry |
is_high_risk and profile ∩ {روان پزشکان، اعصاب و روان} |
+25 |
| High risk + clinical |
is_high_risk and profile ∩ {بالینی، رواندرمانگر} |
+15 |
| Depression severity |
depression ≥ 12 and profile ∩ {افسردگی، خلق} |
+1.5 × depression |
| Anxiety severity |
anxiety ≥ 12 and profile ∩ {اضطراب، وسواس} |
+1.5 × anxiety |
| Ability | always | +6.0 × ability |
| Live availability | free_slots > 0 |
+min(free_slots × 3.0, 20.0) |
is_high_risk is ghq_active and (total_ghq >= 43 or depression >= 15) — see
GHQ-28 Screening.
The availability cap of 20 is the important design decision here: a counselor with 20 open slots and one with 3 differ by 17 points, but neither can out-score a strong clinical match, and the availability bonus cannot exceed the weight of a concept boost.
concept_boost = 1.0
for concept in triggered_concepts:
w = get_learning_weight(p["clean_name"], concept)
if w != 1.0:
concept_boost *= w
final_score = score * concept_boostThe multiplier is applied once per candidate, over every triggered concept, so a counselor with good history in two of the user's three concepts gets the product of both weights.
Results are sorted by score descending and truncated:
scored_list.sort(key=lambda x: x["score"], reverse=True)
return scored_list[:2]Each returned dict has: name, score (rounded to 2 decimals), ability, free_slots, gender,
location, reason.
The reason is assembled from the boosts that actually fired: the first two unique reason strings
joined with "، "; if nothing fired, the default
"تطابق با رده سنی، موضوع مراجع و تخصصهای مرکز". This is why the user-facing card can say
"تخصص در زوجدرمانی و مشاوره پیش از ازدواج" instead of a generic sentence — the reason is
derived from the same signals that produced the score.
The same counselor appears in the Excel roster, in mapping.json, and in the booking website under
slightly different spellings. Three functions handle that.
_TITLE_PATTERNS = [r"\bآقای\b", r"\bخانم\b", r"\bدکتر\b", r"\bدكتر\b", r"^اپراتور\s*\d*"]- Collapse all whitespace runs into single spaces.
- Strip the titles
آقای,خانم,دکتر/دكترand a leadingاپراتور+ digits. -
Unify Arabic/Persian letter variants:
ي → ی,ك → ک. - Replace zero-width space / ZWNJ characters with a real space and collapse again.
Example:
normalize_counselor_name("آقای دکتر محمد علي رضا")
# → "محمد علی رضا"Applied to two names, in order:
- Normalize both, then remove all spaces and compare: equal, or one contained in the other →
True. -
Long common prefix: if both space-stripped names are ≥ 8 characters and the same length, and
they share a prefix of at least 80 % with at most 2 trailing characters differing →
True(theMahsaAmidBeki/MahsaAmidBigicase). -
Token subset: every token of the shorter name must appear in the longer one →
True. This also makes token order irrelevant. -
One fuzzy token: both names must have the same number of tokens (≥ 2).
- 2-token names: both tokens must match exactly or fuzzily.
- 3+-token names: at least 2 exactly shared tokens, and at most 1 fuzzy token.
The "at least 2 exact tokens" rule is what keeps
زهرا مجاهدیandزهرا نیلیfrom matching.
| Case | Rule |
|---|---|
| Identical | match |
| Lengths differ by more than 1, or the shorter token is < 3 chars | no match |
| Same length | match if at most one character differs |
| Lengths differ by exactly 1 | match if deleting one character from the longer token yields the shorter |
This is what lets a scraped محمدرضاکمن match an Excel محمد رضا کمن, and علی vs علي
match after normalization.
parse_age_bounds_perfect(age_str) turns the free-text age_range cell of the spreadsheet into a
numeric (min, max) pair. The rules are applied in this order — the first one that matches
wins:
| Order | Test in the string | Result |
|---|---|---|
| 0 | empty input | (0, 120) |
| 1 | Persian digits ۰۱۲۳۴۵۶۷۸۹ → ASCII |
(pre-processing) |
| 2 | Persian number words (NUM_WORDS) → digits |
(pre-processing) |
| 3 | contains محدودیت خاصی ندارد or بدون محدودیت
|
(0, 120) |
| 4 | contains تا پایان نوجوانی
|
(0, 19) |
| 5 | contains ۱۸الی۰۰۰, 18الی000 or 18 الی
|
(18, 120) |
| 6 | contains به بعد / به بالا / بالای
|
(min(number), 120), or (12, 120) if it also says نوجوانی
|
| 7 | matches `(\d+)\s*(تا | الی |
| 8 | contains زیر or تا and a number was found |
(0, max) — otherwise the test falls through |
| 9 | contains نوجوان and بزرگسال
|
(12, 120) |
| 10 | contains نوجوان
|
(12, 19) |
| 11 | contains جوان or بزرگسال
|
(18, 120) |
| 12 | contains کودک
|
(0, 12) |
| 13 | anything else | (0, 120) |
Verified behaviour:
| Input | Result |
|---|---|
"تا پایان نوجوانی" |
(0, 19) |
"محدودیت خاصی ندارد" |
(0, 120) |
"۱۸الی۰۰۰" / "18الی000"
|
(18, 120) |
"بالای ۱۸ سال" |
(18, 120) |
"بیست سال به بعد" |
(20, 120) — بیست → 20 via NUM_WORDS
|
"نوجوان" |
(12, 19) |
"بزرگسال" |
(18, 120) |
"نوجوان و بزرگسال" |
(12, 120) |
"کودک" |
(0, 12) |
"12 تا 18" |
(12, 18) |
"زیر 12 سال" |
(0, 12) |
"" |
(0, 120) |
NUM_WORDS covers صفر → بیست (0–20): صفر، یک، دو، سه، چهار، پنج، شش، هفت، هشت، نه، ده،
یازده، دوازده، سیزده، چهارده، پانزده، شانزده، هفده، هجده، نوزده، بیست.
ai_knowledge.db holds exactly one table:
CREATE TABLE IF NOT EXISTS concept_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
counselor_name TEXT NOT NULL,
concept TEXT NOT NULL,
ghq_level TEXT DEFAULT 'normal',
positive_signals INTEGER DEFAULT 0,
negative_signals INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(counselor_name, concept)
);One row per (counselor × clinical concept). The UNIQUE constraint makes writes idempotent:
record_feedback_direct() uses INSERT ... ON CONFLICT(counselor_name, concept) DO UPDATE and
increments the counters, so rating the same pair twenty times is twenty increments on one row —
not twenty rows.
w = 1.0 + math.log1p(pos) * 0.12 - neg * 0.10
return max(0.6, min(1.6, w))- Positive signals grow logarithmically — each success is worth less than the last, so one lucky match cannot dominate.
- Negative signals decay linearly — failures hurt immediately and predictably.
-
The range is clamped to
[0.6, 1.6]. A counselor can never be pushed out of contention (×0.6) nor made unbeatable (×1.6).
| positives | negatives | weight |
|---|---|---|
| 0 | 0 | 1.00 (neutral — the default for an unseen pair) |
| 1 | 0 | 1.08 |
| 3 | 0 | 1.17 |
| 10 | 0 | 1.29 |
| 100 | 0 | 1.55 (approaching the 1.6 ceiling asymptotically) |
| 0 | 1 | 0.90 |
| 0 | 2 | 0.80 |
| 0 | 4 | 0.60 (the floor) |
Automatic — from every recommendation. In main.py::_recommend():
ghq_lvl = "severe" if (user_info["ghq_scores"] and user_info["ghq_scores"].get("total", 0) >= 43) else "normal"
for r in recs:
record_learning_event(r["name"], user_info["topic"], ghq_level=ghq_lvl, success=True)record_learning_event() resolves the concept from the topic with _concept_from_topic() and
records a positive signal. This is the self-learning part: the engine reinforces the pairing
it just used, so the next similar request tilts further toward that counselor for that concept.
Manual — from an admin rating. /feedback <n> <counselor name> <بله|خیر> resolves the concept
number through FEEDBACK_CONCEPTS in main.py, fuzzy-matches the counselor name against the
active roster, and calls:
record_feedback_direct(matched, concept, success) # success = بله → True, خیر → Falserecord_feedback_direct() refuses unknown concepts (concept not in EXPANDED_SYNONYMS → returns
False), which keeps the learning table and the synonym dictionary in sync by construction.
-
get_learning_weight(name, concept)→float,1.0when the pair is unknown or the database cannot be read. -
get_learning_stats()→ the 20 most recently updated rows as(counselor, concept, positive, negative, weight)tuples — exactly what/learningprints.
from pathlib import Path
from internal_ai_engine import (
SpiralMatchEngine, get_learning_weight, record_feedback_direct,
)
# 1. Instantiate — loads consultants_profile_full.json and creates ai_knowledge.db
engine = SpiralMatchEngine()
# 2. Ask for matches
results = engine.match({
"age": 34,
"preferred_gender": "خانم", # "خانم" | "آقا" | "فرقی ندارد"
"branch": "خیابان ایران", # "ظفر" | "خیابان ایران" | "اهمیتی ندارد"
"topic": "مشکل با پدر و مادرم دارم و خیلی استرس میگیرم",
"expectation": "میخواهم یک روانشناس خانم داشته باشم",
"ghq_scores": {"somatic": 5, "anxiety": 9, "social": 6, "depression": 12, "total": 32},
})
for r in results:
print(f"{r['name']} score={r['score']} slots={r['free_slots']}")
print(f" why: {r['reason']}")
# 3. Inspect the learned weight for a (counselor × concept) pair
print(get_learning_weight(results[0]["name"], "والد_فرزند")) # e.g. 1.08
# 4. Train it: a positive signal for that pair
record_feedback_direct(results[0]["name"], "والد_فرزند", success=True)
print(get_learning_weight(results[0]["name"], "والد_فرزند")) # slightly higher
# 5. Reload after the dataset changed (this is what /upload does)
engine.reload()Running the engine against an explicit dataset file:
engine = SpiralMatchEngine(profiles_path=Path("consultants_profile_full.json"))If
consultants_profile_full.jsonis missing,engine.profilesis an empty list — no exception is raised, andmatch()returns[]. See Troubleshooting.
- Architecture — where the engine sits in the system.
-
GHQ-28 Screening — the
ghq_scoresthat feed the ranking. -
Admin Panel —
/feedbackand/learning, the admin side of this loop. - Contributing — how to add a synonym without breaking the learning table.