Skip to content

Matching Engine

Mohammad Hasan Kaman edited this page Oct 21, 2026 · 2 revisions

The 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.


Public API

The class

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.

The user_info contract

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.

Module-level helpers

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.

Stage 1 — indexing the roster

_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_COUNSELORS is a hard-coded list of 22 names, and get_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.


Stage 2 — the hard filters

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"] == ["مشاوره حقوقی خانواده"].

The age-relaxation fallback

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.


Stage 3 — weighted scoring

3.1 Concept extraction

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_concepts

is_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.)

3.2 The scoring table

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.

3.3 The learning multiplier and the result

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_boost

The 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.


Name normalization

The same counselor appears in the Excel roster, in mapping.json, and in the booking website under slightly different spellings. Three functions handle that.

normalize_counselor_name(name)

_TITLE_PATTERNS = [r"\bآقای\b", r"\bخانم\b", r"\bدکتر\b", r"\bدكتر\b", r"^اپراتور\s*\d*"]
  1. Collapse all whitespace runs into single spaces.
  2. Strip the titles آقای, خانم, دکتر / دكتر and a leading اپراتور + digits.
  3. Unify Arabic/Persian letter variants: ي → ی, ك → ک.
  4. Replace zero-width space / ZWNJ characters with a real space and collapse again.

Example:

normalize_counselor_name("آقای  دکتر  محمد  علي  رضا")
# → "محمد علی رضا"

names_match(a, b)

Applied to two names, in order:

  1. Normalize both, then remove all spaces and compare: equal, or one contained in the other → True.
  2. 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 (the MahsaAmidBeki / MahsaAmidBigi case).
  3. Token subset: every token of the shorter name must appear in the longer one → True. This also makes token order irrelevant.
  4. 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.

names_match_token(x, y)

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.


Age-bound parsing

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): صفر، یک، دو، سه، چهار، پنج، شش، هفت، هشت، نه، ده، یازده، دوازده، سیزده، چهارده، پانزده، شانزده، هفده، هجده، نوزده، بیست.


The feedback-learning loop

The table

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.

The weight formula

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)

The two write paths

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, خیر → False

record_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.

Reading the weights back

  • get_learning_weight(name, concept) → float, 1.0 when 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 /learning prints.

Using the engine directly

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.json is missing, engine.profiles is an empty list — no exception is raised, and match() returns []. See Troubleshooting.


See also

  • Architecture — where the engine sits in the system.
  • GHQ-28 Screening — the ghq_scores that feed the ranking.
  • Admin Panel — /feedback and /learning, the admin side of this loop.
  • Contributing — how to add a synonym without breaking the learning table.