refactor(policy): build the registries and six engines the architecture asked for

The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine
classes and a stored specificity. What shipped was a single PolicySchema
constant list, six operators, one resolver and a specificity recomputed on
every booking. Each shortcut was defensible on its own; together they left the
starred risk the task itself recorded — a field can be advertised in the form
and supplied by nobody, and the rule silently never matches.

OperatorRegistry now holds all eleven operators. The five that were missing are
real capability, not ceremony: greater_or_equal and less_or_equal make boundary
rules expressible without off-by-one, not_in is the natural way to write an
exclusion, between stops "18 to 65" needing two clauses, and days_since is the
documented operator for "more than N days since" — until now every caller
computed that by hand. between is inclusive at both ends because that is what
the Persian phrasing means and what the user will type.

FieldRegistry is now the single source: it builds the form schema and extracts
the value, so a field that exists in one and not the other is impossible. It
also declares which categories each field belongs to, which is what the closed
list per category used to do separately. Adding it immediately caught its own
first case — last_visit_at was advertised and supplied nowhere, so the guard
now populates it and days_since has something to read.

The six engines are thin on purpose. They give the call site a type — "the
pricing engine" rather than "the resolver with the string pricing" — and a
place for evaluateIsolated, which the sandbox needs to answer "what would this
one rule do". Conflict resolution and effect combination stay in
PolicyResolver: six copies of that would be six places to break.

specificity is a stored column now, computed on save with the documented
weights, and the migration backfills existing rows with the same formula. Left
at zero they would all have tied and the ordering would have changed overnight.

Field names stay as they are rather than moving to the document's dotted names
(patient.age). Stored condition_json rows point at the current names on live
clinic policies; renaming them is a data migration, and the mapping is not
one-to-one — implementation_notes.md says as much.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 15:56:50 +03:30
co-authored by Claude Opus 5
parent 60b4fb9b93
commit f2600f9922
23 changed files with 800 additions and 118 deletions
+39
View File
@@ -421,3 +421,42 @@ POST /api/v1/policy
شرطی که فیلدش در حقایق درخواست نباشد **رد** می‌شود (نه نادیده گرفته) و یک `warning` با
نام قانون و فهرست حقایق موجود لاگ می‌شود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط
می‌رسد عملاً خاموش است و کسی خبردار نمی‌شود.
## رجیستری‌ها و شش موتور
| کلاس | مسئولیت |
|---|---|
| `OperatorRegistry` | یازده عملگر بند ۸، فیلترشده per نوع فیلد |
| `FieldRegistry` | فهرست فیلدها + استخراج مقدار + دسته‌های مجاز — **یک منبع** |
| `PolicyEngine` و شش زیرکلاس | `evaluate()` و `evaluateIsolated()` per دسته |
| `PolicyResolver` | حل تناقض و ترکیب اثرها — یک بار، نه شش بار |
عملگرها: `equals` · `not_equals` · `greater_than` · `greater_or_equal` · `less_than` ·
`less_or_equal` · `in` · `not_in` · `between` · `contains` · `days_since`.
`between` بازهٔ **بسته** است (`[min, max]`) چون «بین ۱۸ تا ۶۵ سال» در فارسی هر دو سر را
شامل می‌شود. `days_since` روی فیلد زمانی کار می‌کند (`last_visit_at`) و «بیش از N روز
گذشته» را می‌سنجد؛ مقدار صفر یعنی «هرگز» و شرط را رد می‌کند.
`FieldRegistry` هم schema فرم را می‌سازد و هم مقدار را استخراج می‌کند. یکی بودنشان همان
چیزی است که خطرِ ثبت‌شدهٔ تسک ۰۹ را می‌بندد: فیلدی که در فرم باشد و هیچ‌کس نسازدش،
بی‌صدا «همیشه‌رد» می‌شود.
شش موتور فقط **تایپ** می‌دهند: نقطهٔ مصرف می‌گوید «موتور قیمت» نه «resolver با رشتهٔ
pricing». منطقشان یکی است و در `PolicyResolver` می‌ماند — شش کپی یعنی شش جای شکستن.
## `specificity`
هنگام **ذخیره** حساب و ذخیره می‌شود:
| وزن | شرط |
|---|---|
| ۸ | شعبهٔ مشخص |
| ۴ | سرویس مشخص |
| ۲ | دستهٔ کاتالوگ مشخص |
| ۱ | هر شرط اضافه |
محاسبه در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود در هر رزرو تکرار شود؛
ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد. مهاجرت `Version20260801122211`
قانون‌های موجود را با همان فرمول پر می‌کند، وگرنه همه صفر می‌ماندند و ترتیب حل تناقض
یک‌شبه عوض می‌شد.