Skip to content

Commit a476c18

Browse files
committed
test(ingestion): offline regression + behavioral tests for UYAP ingestion V1
20 offline tests (no network): six audited price-semantics regression cases (CASE1-5 admissible with auction_price = explicit Ihale Bedeli; CASE6 non-terminal -> EXCLUDED_NON_TERMINAL); extraction != admission; audit does not mutate uyap.json; idempotent admission; duplicate does not create a duplicate genuine observation; missing appraisal / explicit Ihale Bedeli / terminal evidence routing; Odenmesi Gereken Bedel never selected; ALACAGA MAHSUBEN valid; KDV does not alter P/Q; structural freeze (exactly four SMM moments, no uyap_sale_prob). Full suite: 237 passed.
1 parent cb74219 commit a476c18

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

tests/test_uyap_ingestion.py

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
"""UYAP Evidence Ingestion Pipeline V1 testleri (OFFLINE; ağ GEREKTİRMEZ).
2+
3+
Kapsam: 6 regresyon vakası (fiyat semantiği), çıkarım≠admisyon, denetim uyap.json'u
4+
değiştirmez, idempotent admisyon, duplicate → kopya genuine gözlem YOK, eksik-kanıt
5+
yönlendirmesi, Ödenmesi Gereken Bedel asla auction price DEĞİL, ALACAĞA MAHSUBEN geçerli
6+
İhale Bedeli'ni geçersiz kılmaz, KDV P/Q'yu değiştirmez, uyap_sale_prob YOK, terminal-olmayan
7+
negatif sınıfa ÇEVRİLMEZ, SMM tam olarak 4 moment kalır (yapısal FREEZE).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import shutil
14+
15+
import pytest
16+
17+
from sold.ingestion.uyap import (
18+
ADMISSIBLE_COMPLETED_SALE,
19+
DUPLICATE,
20+
EXCLUDED_NON_TERMINAL,
21+
MISSING_APPRAISAL,
22+
MISSING_AUCTION_PRICE,
23+
MISSING_TERMINAL_EVIDENCE,
24+
admit_candidate,
25+
discover,
26+
import_artifact,
27+
needs_review,
28+
parse_tl_amount,
29+
record_exclusion,
30+
review_queue,
31+
run_audit,
32+
run_extract,
33+
status_summary,
34+
store,
35+
)
36+
37+
38+
# --------------------------------------------------------------------------- #
39+
# Artifact kurucusu (offline fixture; kişisel veri YOK)
40+
# --------------------------------------------------------------------------- #
41+
def _artifacts(appraisal, ihale=None, status="Satıldı Satış İşlemleri Tamamlandı",
42+
odenmesi=None, teminat=None, share=False, alacaga=False, kdv=None,
43+
ada="6646", parsel="6", card=None, appraisal_in_result=True):
44+
appr = (f"Bilirkişi Raporu Muhammen Bedel {appraisal} TL Kıymeti {appraisal} TL "
45+
f"Takdir Olunan Değer {appraisal} TL {ada} ada {parsel} parsel mesken")
46+
res = f"İhale Artırma Sonuç Tutanağı {ada} ada {parsel} parsel "
47+
if ihale is not None:
48+
res += f"İhale Bedeli: {ihale} TL "
49+
if odenmesi is not None:
50+
res += f"Ödenmesi Gereken Bedel: {odenmesi} "
51+
if teminat is not None:
52+
res += f"Teminat: {teminat} TL "
53+
if share:
54+
res += "İhale Alıcısı Hisse Oranı %33.33 Satılan Hisse Oranı %66.66 "
55+
if alacaga:
56+
res += "Ödenmesi Gereken Bedel: ALACAĞA MAHSUBEN "
57+
if kdv is not None:
58+
res += f"KDV %{kdv} "
59+
card_amt = card if card is not None else (ihale or "")
60+
cardtxt = f"{status} Satış Tutarı: {card_amt} TL"
61+
arts = [
62+
{"artifact_type": "appraisal_report", "text": appr},
63+
{"artifact_type": "auction_result", "text": res},
64+
{"artifact_type": "status_card", "text": cardtxt},
65+
]
66+
return arts
67+
68+
69+
def _pipeline_to_audit(file_id, artifacts, store_dir, genuine_path=None, institution="Ankara İcra"):
70+
"""discover → import → extract → audit; adayı döndürür (admisyon YOK)."""
71+
c = discover(institution, file_id, store_dir=store_dir)
72+
for a in artifacts:
73+
import_artifact(c, a["artifact_type"], text=a["text"], store_dir=store_dir, persist=False)
74+
store.upsert(c, store_dir)
75+
c = run_extract(c, store_dir)
76+
c = run_audit(c, store_dir, genuine_path)
77+
return c
78+
79+
80+
# --------------------------------------------------------------------------- #
81+
# 6 regresyon vakası — fiyat semantiği
82+
# --------------------------------------------------------------------------- #
83+
CASES = {
84+
"CASE1": (_artifacts("4.400.000,00", "4.238.000,00", odenmesi="3.393.710,93", share=True, ada="6646", parsel="6"),
85+
ADMISSIBLE_COMPLETED_SALE, 4_238_000.0, 4_400_000.0),
86+
"CASE2": (_artifacts("3.000.000,00", "3.025.000,00", odenmesi="2.725.000,00", teminat="300.000", ada="6441", parsel="9"),
87+
ADMISSIBLE_COMPLETED_SALE, 3_025_000.0, 3_000_000.0),
88+
"CASE3": (_artifacts("5.750.000,00", "4.575.000,00", odenmesi="3.050.000,02", share=True, ada="25043", parsel="1"),
89+
ADMISSIBLE_COMPLETED_SALE, 4_575_000.0, 5_750_000.0),
90+
"CASE4": (_artifacts("5.400.000,00", "4.654.000,00", odenmesi="4.114.000", ada="14708", parsel="3"),
91+
ADMISSIBLE_COMPLETED_SALE, 4_654_000.0, 5_400_000.0),
92+
"CASE5": (_artifacts("6.800.000,00", "5.715.000,00", alacaga=True, kdv="20", ada="50984", parsel="1"),
93+
ADMISSIBLE_COMPLETED_SALE, 5_715_000.0, 6_800_000.0),
94+
"CASE6": (_artifacts("8.000.000,00", "6.700.000,00", status="Birinci Alıcıya Süre Verildi", ada="123", parsel="4"),
95+
EXCLUDED_NON_TERMINAL, None, 8_000_000.0),
96+
}
97+
98+
99+
@pytest.mark.parametrize("name", list(CASES))
100+
def test_regression_case_decision_and_price(name, tmp_path):
101+
artifacts, exp_dec, exp_price, exp_appraisal = CASES[name]
102+
c = _pipeline_to_audit(f"{name}/2026", artifacts, tmp_path)
103+
au = c["audit"]
104+
assert au["decision"] == exp_dec
105+
if exp_price is not None:
106+
assert au["auction_price"] == exp_price # pay = açık İhale Bedeli
107+
assert au["appraisal_value"] == exp_appraisal # payda = ekspertiz Q
108+
# Ödenmesi Gereken Bedel / payable ASLA seçilmez
109+
og = c["extracted"].get("odenmesi_gereken_bedel")
110+
if og is not None:
111+
assert au["auction_price"] != og
112+
113+
114+
def test_case5_alacaga_mahsuben_valid_and_kdv_no_effect(tmp_path):
115+
artifacts, _, _, _ = CASES["CASE5"]
116+
c = _pipeline_to_audit("CASE5/2026", artifacts, tmp_path)
117+
au = c["audit"]
118+
assert au["decision"] == ADMISSIBLE_COMPLETED_SALE
119+
assert au["auction_price"] == 5_715_000.0 # ALACAĞA MAHSUBEN açık İhale Bedeli'ni GEÇERSİZ KILMAZ
120+
assert c["extracted"]["alacaga_mahsuben"] is True
121+
assert c["extracted"]["kdv_rate"] == 20.0
122+
# KDV P veya Q'yu DEĞİŞTİRMEZ (moment tanımı korunur)
123+
assert au["win_over_appraisal"] == pytest.approx(5_715_000.0 / 6_800_000.0, abs=1e-12)
124+
125+
126+
def test_odenmesi_gereken_bedel_never_auction_price(tmp_path):
127+
artifacts, _, _, _ = CASES["CASE2"]
128+
c = _pipeline_to_audit("CASE2b/2026", artifacts, tmp_path)
129+
assert c["audit"]["auction_price"] == 3_025_000.0
130+
assert c["extracted"]["odenmesi_gereken_bedel"] == 2_725_000.0
131+
assert c["audit"]["auction_price"] != c["extracted"]["odenmesi_gereken_bedel"]
132+
# kural izinde Ödenmesi Gereken Bedel'in kullanılmadığı açıkça yazılı
133+
assert any("NOT used as auction price" in t for t in c["audit"]["rule_trace"])
134+
135+
136+
def test_non_terminal_excluded_not_negative_class(tmp_path):
137+
artifacts, _, _, _ = CASES["CASE6"]
138+
c = _pipeline_to_audit("2026/316 Talimat", artifacts, tmp_path)
139+
assert c["audit"]["decision"] == EXCLUDED_NON_TERMINAL
140+
# dışlanan manifest kaydı: genuine sete / SMM'e GİRMEZ; negatif sınıf YOK
141+
excl_path = tmp_path / "uyap_candidates.json"
142+
r = record_exclusion(c, candidates_path=excl_path, store_dir=tmp_path)
143+
assert r["status"] == "recorded"
144+
manifest = json.loads(excl_path.read_text(encoding="utf-8"))
145+
entry = manifest[0]
146+
assert entry["audit_status"] == EXCLUDED_NON_TERMINAL
147+
assert entry["enters_genuine_uyap"] is False and entry["enters_smm"] is False
148+
# negatif sınıf / sale_prob VERİ ALANI üretilmez (not: not'ta olumsuzlama olarak geçebilir)
149+
assert not any("sale_prob" in str(k) for k in entry.keys())
150+
151+
152+
# --------------------------------------------------------------------------- #
153+
# çıkarım ≠ admisyon ; denetim uyap.json'u değiştirmez
154+
# --------------------------------------------------------------------------- #
155+
def _seed_genuine(tmp_path):
156+
gp = tmp_path / "uyap.json"
157+
gp.write_text("[]", encoding="utf-8")
158+
return gp
159+
160+
161+
def test_extract_is_not_admission(tmp_path):
162+
gp = _seed_genuine(tmp_path)
163+
artifacts, _, _, _ = CASES["CASE1"]
164+
c = discover("Ankara İcra", "EX/2026", store_dir=tmp_path)
165+
for a in artifacts:
166+
import_artifact(c, a["artifact_type"], text=a["text"], store_dir=tmp_path, persist=False)
167+
store.upsert(c, tmp_path)
168+
c = run_extract(c, tmp_path)
169+
assert c["extracted"]["ihale_bedeli"] == 4_238_000.0
170+
assert c.get("admitted_public_record_id") is None
171+
assert json.loads(gp.read_text(encoding="utf-8")) == [] # genuine değişmedi
172+
173+
174+
def test_audit_does_not_mutate_uyap(tmp_path):
175+
gp = _seed_genuine(tmp_path)
176+
artifacts, _, _, _ = CASES["CASE1"]
177+
_pipeline_to_audit("AUD/2026", artifacts, tmp_path, genuine_path=gp)
178+
assert json.loads(gp.read_text(encoding="utf-8")) == [] # denetim yazmadı
179+
180+
181+
def test_explicit_admission_idempotent(tmp_path):
182+
gp = _seed_genuine(tmp_path)
183+
artifacts, _, _, _ = CASES["CASE1"]
184+
c = _pipeline_to_audit("ADM/2026", artifacts, tmp_path, genuine_path=gp)
185+
r1 = admit_candidate(c, genuine_path=gp, store_dir=tmp_path)
186+
assert r1["status"] == "admitted"
187+
recs = json.loads(gp.read_text(encoding="utf-8"))
188+
assert len(recs) == 1 and recs[0]["public_record_id"] == "ADM/2026"
189+
assert recs[0]["winning_bid"] == 4_238_000.0 and recs[0]["appraised_value"] == 4_400_000.0
190+
# tekrar admisyon → KOPYA YOK
191+
c2 = store.get_candidate(c["candidate_id"], tmp_path)
192+
r2 = admit_candidate(c2, genuine_path=gp, store_dir=tmp_path)
193+
assert r2["status"] == "already_admitted"
194+
assert len(json.loads(gp.read_text(encoding="utf-8"))) == 1
195+
196+
197+
def test_duplicate_candidate_no_duplicate_genuine(tmp_path):
198+
gp = _seed_genuine(tmp_path)
199+
artifacts, _, _, _ = CASES["CASE1"]
200+
c = _pipeline_to_audit("DUP/2026", artifacts, tmp_path, genuine_path=gp)
201+
admit_candidate(c, genuine_path=gp, store_dir=tmp_path)
202+
# aynı dosya yeniden denetlenirse DUPLICATE olarak işaretlenir
203+
c2 = _pipeline_to_audit("DUP/2026", artifacts, tmp_path, genuine_path=gp)
204+
assert c2["audit"]["decision"] == DUPLICATE
205+
assert len(json.loads(gp.read_text(encoding="utf-8"))) == 1
206+
207+
208+
# --------------------------------------------------------------------------- #
209+
# eksik-kanıt yönlendirmesi
210+
# --------------------------------------------------------------------------- #
211+
def test_missing_terminal_goes_to_review(tmp_path):
212+
arts = _artifacts("4.400.000,00", "4.238.000,00", status="", ada="6646", parsel="6")
213+
c = _pipeline_to_audit("MT/2026", arts, tmp_path)
214+
assert c["audit"]["decision"] == MISSING_TERMINAL_EVIDENCE
215+
assert needs_review(c)
216+
217+
218+
def test_missing_appraisal_blocked(tmp_path):
219+
arts = [
220+
{"artifact_type": "auction_result", "text": "6646 ada 6 parsel İhale Bedeli: 4.238.000,00 TL"},
221+
{"artifact_type": "status_card", "text": "Satıldı Satış İşlemleri Tamamlandı Satış Tutarı: 4.238.000,00 TL"},
222+
]
223+
c = _pipeline_to_audit("MA/2026", arts, tmp_path)
224+
assert c["audit"]["decision"] in (MISSING_APPRAISAL,) # ekspertiz yok → bloklandı
225+
assert needs_review(c)
226+
227+
228+
def test_missing_explicit_ihale_blocked(tmp_path):
229+
# terminal + ekspertiz var; açık İhale Bedeli YOK ve sonuç kartı da yok → bloklandı
230+
arts = [
231+
{"artifact_type": "appraisal_report", "text": "Muhammen Bedel 4.400.000,00 TL 6646 ada 6 parsel"},
232+
{"artifact_type": "auction_result", "text": "6646 ada 6 parsel Satıldı Satış İşlemleri Tamamlandı"},
233+
]
234+
c = _pipeline_to_audit("MI/2026", arts, tmp_path)
235+
assert c["audit"]["decision"] == MISSING_AUCTION_PRICE
236+
assert c["audit"]["auction_price"] is None
237+
238+
239+
# --------------------------------------------------------------------------- #
240+
# operatör status / review
241+
# --------------------------------------------------------------------------- #
242+
def test_status_summary_and_review_queue(tmp_path):
243+
_pipeline_to_audit("CASE1/2026", CASES["CASE1"][0], tmp_path) # admissible
244+
_pipeline_to_audit("2026/316 Talimat", CASES["CASE6"][0], tmp_path) # excluded
245+
_pipeline_to_audit("MT/2026", _artifacts("4.400.000,00", "4.238.000,00", status="", ada="6646", parsel="6"), tmp_path) # review
246+
s = status_summary(tmp_path)
247+
assert s["total_candidates"] == 3
248+
assert s["admissible"] == 1 and s["excluded_non_terminal"] == 1
249+
assert s["review_blockers"] >= 1
250+
q = review_queue(tmp_path)
251+
assert any(it["audit_decision"] == MISSING_TERMINAL_EVIDENCE for it in q)
252+
253+
254+
# --------------------------------------------------------------------------- #
255+
# YAPISAL FREEZE — SMM tam olarak 4 moment; uyap_sale_prob YOK; TOKİ external
256+
# --------------------------------------------------------------------------- #
257+
def test_structural_freeze_four_moments_no_sale_prob():
258+
from sold.structural import build_observed_moments, load_genuine_datasets
259+
260+
g = load_genuine_datasets()
261+
built = build_observed_moments(g["uyap"], g["kap"], g["toki_result"])
262+
smm = {k for k in built["moments"] if k.startswith(("uyap_win", "kap_log"))}
263+
assert smm == {
264+
"uyap_win_over_appraisal_mean", "uyap_win_over_appraisal_sd",
265+
"kap_log_ratio_mean", "kap_log_ratio_sd",
266+
}
267+
assert "uyap_sale_prob" not in built["moments"]
268+
269+
270+
def test_admission_feeds_existing_frozen_schema(tmp_path):
271+
from sold.structural import build_observed_moments, load_genuine_datasets
272+
from sold.structural.datasets import GENUINE_DIR
273+
274+
gdir = tmp_path / "genuine"
275+
gdir.mkdir()
276+
for f in ("uyap.json", "kap.json", "toki.json"):
277+
shutil.copyfile(GENUINE_DIR / f, gdir / f)
278+
before = len(json.loads((gdir / "uyap.json").read_text(encoding="utf-8")))
279+
c = _pipeline_to_audit("NEWADM/2026", CASES["CASE1"][0], tmp_path, genuine_path=gdir / "uyap.json")
280+
r = admit_candidate(c, genuine_path=gdir / "uyap.json", store_dir=tmp_path)
281+
assert r["status"] == "admitted"
282+
after = len(json.loads((gdir / "uyap.json").read_text(encoding="utf-8")))
283+
assert after == before + 1 # non-destructive append
284+
g = load_genuine_datasets(directory=gdir)
285+
built = build_observed_moments(g["uyap"], g["kap"], g["toki_result"])
286+
smm = {k for k in built["moments"] if k.startswith(("uyap_win", "kap_log"))}
287+
assert smm == {
288+
"uyap_win_over_appraisal_mean", "uyap_win_over_appraisal_sd",
289+
"kap_log_ratio_mean", "kap_log_ratio_sd",
290+
}
291+
assert "uyap_sale_prob" not in built["moments"] # negatif sınıf üretilmedi
292+
293+
294+
# --------------------------------------------------------------------------- #
295+
# parse_tl_amount birim testleri (fiyat semantiği çekirdeği)
296+
# --------------------------------------------------------------------------- #
297+
def test_parse_tl_amount_formats():
298+
assert parse_tl_amount("4.238.000,00 TL") == 4_238_000.0
299+
assert parse_tl_amount("3.025.000,00") == 3_025_000.0
300+
assert parse_tl_amount("6700000") == 6_700_000.0
301+
assert parse_tl_amount("ALACAĞA MAHSUBEN") is None # nakit uydurulmaz
302+
assert parse_tl_amount(": ALACAĞA MAHSUBEN 20") is None # mahsuben sayıdan önce → None
303+
# sayı mahsuben'den ÖNCE → gerçek tutar (İhale Bedeli, ardından mahsuben gelen alan)
304+
assert parse_tl_amount("5.715.000,00 TL Ödenmesi Gereken Bedel: ALACAĞA MAHSUBEN") == 5_715_000.0

0 commit comments

Comments
 (0)