Repository navigation
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.
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.
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
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. |
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.
Every surviving counselor gets a score built from additive rules. This is the complete list, with the values from the code:
+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 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.
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.
+ 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 نوبت آزاد در دسترس.
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_boostSo 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.
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_signalsThe 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.
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:
تطابق با رده سنی، موضوع مراجع و تخصصهای مرکز
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:
-
Normalise — collapse whitespace, strip the titles
آقای/خانم/دکتر/دكتر/اپراتور N, unify Arabicي→یandك→ک, and normalise zero-width separators. - Compare without spaces — exact, or one contains the other.
-
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محسا امیدبیکی. - Token-subset rule — every token of the shorter name appears in the longer one.
-
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).
- 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.
- GHQ-28 Screening — the instrument that produces the scores the engine reads.
-
Intake-Flow — where the
user_infodict 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.