feat(projekt-matching): flow-2 stages with gate, CRM verify, notification, langfuse trace

This commit is contained in:
tlg
2026-07-09 11:06:11 +02:00
parent 11b5675eab
commit 2032305d90
3 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import datetime as dt
from unittest import mock
from projektmatch import stages
from projektmatch.config import Cfg
CFG = Cfg(imap_password="pw", espo_base="https://crm/api/v1",
espo_api_key="k", notify_to="n@x", alert_to="a@x",
vllm_base="http://v/v1", vllm_model="m", data_dir="/tmp")
EXTRACT = {
"projectName": "Python Entwickler KI",
"offerType": "Projekt", "buyerType": "agency",
"companyName": "Aristo Group", "contactPerson": "Max Mann",
"requirements": [
{"text": "Python", "kat": "Must", "miscType": "other",
"startDate": None, "workloadPercent": None, "remotePercent": None,
"onsiteLocation": None},
{"text": "K8s", "kat": "Nice", "miscType": "other",
"startDate": None, "workloadPercent": None, "remotePercent": None,
"onsiteLocation": None},
{"text": "100 % remote", "kat": "Misc", "miscType": "location",
"startDate": None, "workloadPercent": None, "remotePercent": 100,
"onsiteLocation": None},
],
}
def ctx_after_match(ratings):
ctx = {"canonical": "https://x/projekt/p", "title": "t", "status": "ok",
"extract": dict(EXTRACT)}
ctx["ratings"] = ratings
return ctx
def test_run_stage_catches_and_skips():
def boom(ctx, cfg):
raise ValueError("kaputt")
ctx = stages.run_stage("fetch", boom, {"status": "ok"}, CFG)
assert ctx["status"] == "failed" and "fetch: kaputt" in ctx["error"]
untouched = stages.run_stage("extract", boom, dict(ctx), CFG)
assert untouched["status"] == "failed" # boom not called again
def test_stage_rules_consider_and_reject():
yes = [{"nr": 1, "rating": "yes", "reason": "ok"},
{"nr": 2, "rating": "yes", "reason": "ok"}]
ctx = stages.stage_rules(ctx_after_match(yes), CFG)
assert ctx["decision"] == "consider" and ctx["mustMatch"] == 100
assert ctx["status"] == "ok"
assert "| 1 | Must | ✅ | Python |" in ctx["description"]
no = [{"nr": 1, "rating": "no", "reason": "fehlt"},
{"nr": 2, "rating": "yes", "reason": "ok"}]
ctx2 = stages.stage_rules(ctx_after_match(no), CFG)
assert ctx2["status"] == "rejected" and ctx2["decision"] == "rejected"
assert ctx2["mustMatch"] == 0
def test_stage_crm_agency_linking_and_verify():
ctx = ctx_after_match([{"nr": 1, "rating": "yes", "reason": "ok"},
{"nr": 2, "rating": "yes", "reason": "ok"}])
ctx = stages.stage_rules(ctx, CFG)
espo = mock.Mock()
espo.team_id.return_value = "T1"
espo.ensure_account.return_value = "A1"
espo.ensure_contact.return_value = "C1"
espo.unique_opportunity_name.return_value = "Python Entwickler KI"
espo.create_opportunity.return_value = {"id": "O1"}
espo.get_opportunity.return_value = {
"id": "O1", "name": "Python Entwickler KI",
"cProjektlink": "https://x/projekt/p",
"description": ctx["description"], "cAccount1Id": "A1",
"accountId": None, "teamsIds": ["T1"]}
out = stages.stage_crm(ctx, CFG, espo=espo)
assert out["status"] == "created" and out["opportunityId"] == "O1"
assert out["crmUrl"].endswith("#Opportunity/view/O1")
payload = espo.create_opportunity.call_args.args[0]
assert payload["cAccount1Id"] == "A1" and "accountId" not in payload
assert payload["teamsIds"] == ["T1"]
espo.ensure_account.assert_called_with("Aristo Group", "Reseller")
def test_stage_crm_skips_when_rejected():
ctx = {"status": "rejected", "decision": "rejected"}
espo = mock.Mock()
assert stages.stage_crm(ctx, CFG, espo=espo)["status"] == "rejected"
espo.team_id.assert_not_called()
def test_stage_notify_only_on_created():
sent = []
with mock.patch("projektmatch.stages.mailer.send_mail",
lambda *a: sent.append(a)), \
mock.patch("projektmatch.stages.pm_trace.post_trace", lambda c: None):
ctx = {"status": "created", "projectName": "P", "mustMatch": 90,
"niceMatch": None, "crmUrl": "https://crm/#Opportunity/view/O1"}
stages.stage_notify(dict(ctx), CFG)
assert sent[0][2] == "n@x"
assert sent[0][3] == "[Projekt-Match] Freelancermap — 90 % — P"
sent.clear()
stages.stage_notify({"status": "rejected"}, CFG)
assert not sent