Skip to content

Matching Engine

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

Matching Engine

app/internal_ai_engine.py contains SpiralMatchEngine — the component that decides which two counselors a visitor is shown. It is a rule-based, transparent scorer, not a language model: every point in the final number comes from a named rule you can point at in the source.

The method is engine.match(user_info, tenant_id). It returns at most two counselors, each with a score, an ability value, a free-slot count, gender, location and a Persian reason string.


What goes in

user_info = {
    "age": 34,
    "gender": "خانم",
    "preferred_gender": "خانم",     # or "آقا" / "فرقی ندارد"
    "branch": "ظفر",                 # or "خیابان ایران" / "اهمیتی ندارد"
    "location": "",                  # falls back to branch
    "topic": "مشکل ارتباطی با همسرم دارم",
    "expectation": "می‌خواهم یک جلسه مشاوره زوج‌درمانی داشته باشم",
    "ghq_scores": {"somatic": 4, "anxiety": 9, "social": 3, "depression": 2, "total": 18},
}

The topic and the expectation are concatenated and lower-cased into one string, called user_topic below. Everything keyword-based reads from that single string.


Step 1 — Concept extraction

EXPANDED_SYNONYMS maps 11 clinical concepts to their Persian keyword lists:

Concept Keywords include
والد_فرزند پدر، مادر، فرزند، خانواده، تربیت، فرزندپروری، والدگری …
ارتباط_تعارض اختلاف، تعارض، دعوا، گفتگو، مهارت‌های ارتباطی …
اضطراب استرس، نگرانی، پانیک، ترس، فوبیا، anxiety …
افسردگی غم، پوچی، ناامیدی، بی‌حوصلگی, depression, سوگ …
وسواس وسواسی، شستشو، فکر تکراری, ocd, نشخوار فکری …
زوج_ازدواج همسر، ازدواج، طلاق، خیانت، زناشویی, زوج …
کودک بچه، کودک، بیش‌فعالی, adhd, اوتیسم, شب ادراری, یادگیری …
نوجوان_جوان بلوغ، افت تحصیلی, کنکور, انتخاب رشته, هویت, نوجوان …
توسعه_فردی عزت نفس, کوچینگ, خودشناسی, کمال‌گرایی, مهارت‌های زندگی …
حقوقی مهریه, حضانت, نفقه, دادگاه, وکیل, ارث, وکالت …
پزشکی_روانپزشکی دارو, روانپزشک, اعصاب و روان, دوقطبی, سایکوتیک …

A concept is triggered when any one of its keywords appears as a substring in user_topic. This is a substring test, not tokenisation — which is why "خانواده" also fires on "خانوادگی" and "خانواده‌درمانی".

Two concepts get special treatment right away:

  • حقوقی → is_legal_request
  • پزشکی_روانپزشکی → is_medical_request

Step 2 — Hard filters

The roster is loaded per tenant from the consultants table and cached in a thread-safe dict (_tenant_cache + _cache_lock). Tenant 1 falls back to the JSON profile file if the table is empty.

A counselor survives the hard filters only if all of these hold:

Filter Rule
Gender If the visitor asked for "خانم"/"زن", only female counselors survive; "آقا"/"مرد" → only male. "فرقی ندارد" → no filter.
Branch If the visitor chose ظفر or خیابان ایران, the counselor's location must be that branch or هر دو (works at both). A counselor who works only at the other branch is dropped.
Age If the visitor's age is known, the counselor's range must cover it, with ±1 year of tolerance: age_min - 1 <= user_age <= age_max + 1.
Legal Unless the request is itself legal (حقوقی), a counselor whose education_experience contains وکالت — or whose only specialisation is مشاوره حقوقی خانواده — is dropped. A visitor asking for legal help should not be handed to a family-lawyer-only profile.

The three fallback passes

Hard filters can empty the list. The engine then relaxes, in this order:

Pass What is dropped What is kept
1 The age filter. Gender, branch, legal.
2 The age filter and the branch filter. Gender, legal.

Pass 2 is the "last line of defence" — the comment in the code says it plainly: the visitor must always get an answer. Only the gender preference and the legal guard survive it.


Step 3 — Scoring

Every surviving counselor gets a score built from additive rules. This is the complete list, with the values from the code:

Word overlap

+12.0   for every visitor word (>2 chars) that appears in the counselor's search text

The search text is the concatenation of the counselor's name, education, general areas, detailed topics, specialisations and notes. The visitor's words come from re.findall(r'\w+', user_topic) filtered to length > 2.

Concept rules

Concept triggered Counselor text contains Bonus Reason shown
حقوقی وکالت / حقوقی / دادگاه / حقوق خانواده +80 مشاوره تخصصی حقوقی خانواده
پزشکی_روانپزشکی روان پزشکان / اعصاب و روان / دارو / طب ایرانی +80 متخصص اعصاب و روان (روانپزشک)
جنس مخالف (literal phrase) جنس مخالف +50 تخصص ویژه در روابط عاطفی و ارتباط با جنس مخالف
والد_فرزند والد / فرزند / والدگری / خانواده +50 تخصص در بهبود روابط و تعارضات والد و فرزند
زوج_ازدواج زوج / ازدواج / زناشویی / همسر +45 تخصص در زوج‌درمانی و مشاوره پیش از ازدواج
وسواس وسواس / وسواسی / ocd +45 درمان تخصصی وسواس فکری و عملی (OCD)
اضطراب اضطراب / استرس / پانیک / فوبیا +40 درمان تخصصی اضطراب و استرس
افسردگی افسردگی / خلق / خلقی / سوگ / طرحواره +40 درمان تخصصی افسردگی و افت انگیزه
کودک کودک / کودکان / فرزندپروری / بازی +40 تخصص در روانشناسی کودک و فرزندپروری
sexual-health keywords جنسی / سکس / سکستراپی / مامایی +45 تخصص در سلامت جنسی و مشکلات زناشویی
education keywords کنکور / انتخاب رشته / تحصیلی +45 مشاوره تخصصی تحصیلی، کنکور و هدایت شغلی
learning-disability keywords یادگیری / نقص توجه / بیش فعالی / adhd +45 درمان تخصصی اختلالات یادگیری و بیش‌فعالی (ADHD)
ارتباط_تعارض بین‌فردی / روابط / ارتباط / تعارض / مهارت +45 تخصص در مهارت‌های ارتباطی و حل تعارضات بین‌فردی
نوجوان_جوان or age 12–19 نوجوان / نوجوانی / جوان +35 مشاوره تخصصی رده سنی نوجوان و جوان
توسعه_فردی توسعه فردی / عزت نفس / کوچینگ / کمال +35 مشاوره توسعه فردی و عزت‌نفس

Two of these fire on age alone, not on the concept: a visitor aged 12–19 gets the youth rule, and a visitor under 12 gets the child rule. The age band widens the condition — it does not replace it.

GHQ rules

The GHQ scores are only used if the visitor actually completed the screen ("total" in ghq). Then:

Condition Counselor text contains Bonus Reason shown
High risk (total >= 43 or depression >= 15) روان پزشکان / اعصاب و روان +60 ارزیابی بالینی و روانپزشکی بر اساس غربالگری GHQ
High risk بالینی / رواندرمانگر +35 ارزیابی روان‌شناختی تخصصی بر اساس غربالگری GHQ
depression >= 12 افسردگی / خلق depression × 1.5 —
anxiety >= 12 اضطراب / وسواس anxiety × 1.5 —

The first two are if / elif — a psychiatrist-eligible profile gets the +60 and does not also collect the +35.

The last two are continuous: a visitor with a depression subscore of 20 adds 20 × 1.5 = 30 points to every depression-capable counselor, and an anxiety subscore of 15 adds 22.5 to every anxiety-capable one. This is how a high GHQ score pushes the result toward clinical profiles without a hard rule.

Standing bonuses

+ ability × 6.0        the counselor's ability coefficient (1.0, 2.0 or 3.0)
+ min(free_slots × 3.0, 20.0)

The free-slot bonus is capped at 20, so availability helps but never dominates. The visitor is told only that a slot is available, never how many — the exact count is for the admin panel. The reason string is نوبت آزاد در دسترس.


Step 4 — The learning multiplier

This is the part that makes the engine improve over time.

Every (counselor, concept) pair carries a weight, stored in the concept_feedback table and read once per match into learning_w. The weight is:

w = 1.0 + math.log1p(positive_signals) * 0.12 - negative_signals * 0.10
w = max(0.6, min(1.6, w))
  • Successes grow logarithmically — the first success is worth much more than the tenth, so a single lucky match cannot dominate.
  • Failures decay linearly — every rejection subtracts a flat 0.10.
  • The range is clamped to [0.6, 1.6] — the engine can shift a ranking, but it can never invert it or erase a strong structural match.

If the visitor triggered several concepts, the weights are multiplied together:

concept_boost = 1.0
for concept in triggered_concepts:
    w = learning_w.get((p["clean_name"], concept), 1.0)
    if w != 1.0:
        concept_boost *= w
final_score = score * concept_boost

So a counselor who is repeatedly the right choice for اضطراب gets a small, compounding lift on anxiety cases — and a counselor who keeps being wrong for a concept loses ground on that concept only.

Where the feedback comes from

Staff mark a recommendation right or wrong in the admin panel (POST /admin/feedback), with a counselor name, one of the 11 concepts, and a verdict. record_feedback_direct() writes a row:

INSERT INTO concept_feedback (tenant_id, counselor_name, concept, ghq_level,
                               positive_signals, negative_signals)
ON CONFLICT(tenant_id, counselor_name, concept) DO UPDATE SET
    positive_signals = positive_signals + excluded.positive_signals,
    negative_signals = negative_signals + excluded.negative_signals

The table is unique on (tenant_id, counselor_name, concept), so the counters are per tenant, per counselor, per concept. A concept that is not in EXPANDED_SYNONYMS is rejected — record_feedback_direct() returns False.


Step 5 — The result

The list is sorted by score, descending, and truncated to two:

scored_list.sort(key=lambda x: x["score"], reverse=True)
return scored_list[:2]

Each entry:

{
    "name": "دکتر سارا امیدبیگی",
    "score": 187.4,
    "ability": 3.0,
    "free_slots": 4,
    "gender": "خانم",
    "location": "هر دو",
    "reason": "تخصص در زوج‌درمانی و مشاوره پیش از ازدواج، درمان تخصصی اضطراب و استرس",
}

The reason is the first two collected reasons, joined with "، ", de-duplicated in order. If no rule fired, the fallback is:

تطابق با رده سنی، موضوع مراجع و تخصص‌های مرکز


Name matching

The counselor roster comes from an Excel file; the availability data comes from a web scrape. The two never spell a name identically, so names_match() is deliberately forgiving:

  1. Normalise — collapse whitespace, strip the titles آقای / خانم / دکتر / دكتر / اپراتور N, unify Arabic ي→ی and ك→ک, and normalise zero-width separators.
  2. Compare without spaces — exact, or one contains the other.
  3. Long-prefix rule — single-token names of equal length (≥ 8 chars) that share an 80% prefix with at most 2 trailing characters apart match. This is what makes محسا امیدبیگی match محسا امیدبیکی.
  4. Token-subset rule — every token of the shorter name appears in the longer one.
  5. One-letter-difference rule — with a safety condition, so that زهرا مجاهدی and زهرا نیلی do not match. For a 2-token name, both tokens must match exactly or fuzzily. For a 3+ token name, at least 2 tokens must match exactly and at most 1 may differ by a letter.

Because this function is expensive and the same pairs repeat, it is cached in _name_match_cache (a dict guarded by a lock, cleared when it exceeds 50,000 entries).


What the engine does not do

  • It does not diagnose. The output is a counselor recommendation with reasons, not a clinical opinion. The GHQ screen is a screening instrument, and the widget presents it as such.
  • It does not use an LLM. Every score is a literal addition in the source. There is no model call, no embedding, no vector search — despite the name of the file, the matching is deterministic string work over a small roster.
  • It does not use the free-slot count as a tiebreaker beyond the cap. Twenty points is the ceiling, and it is added before the learning multiplier.

Related pages

  • GHQ-28 Screening — the instrument that produces the scores the engine reads.
  • Intake-Flow — where the user_info dict comes from.
  • Crawler — where the free-slot data and the booking links come from.
  • Admin Panel — the feedback form that drives the learning weights.
  • Architecture — where the engine sits in the request pipeline.

Clone this wiki locally