service.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. from __future__ import annotations
  2. from datetime import date, datetime
  3. import json
  4. from pathlib import Path
  5. from threading import RLock
  6. from uuid import uuid4
  7. from zoneinfo import ZoneInfo
  8. from rdflib import Graph, Literal, Namespace, RDF, URIRef, XSD
  9. from app.market.filters import rejection_reasons
  10. from app.market.models import CandidateRecord, InquiryRequest
  11. from app.market.pricing import build_prices
  12. from app.market.scoring import CustomerSignals, score_candidates
  13. from app.repository import RDFRepository
  14. GESLI = Namespace("https://gesli.example/ontology#")
  15. RUN = Namespace("https://gesli.example/recommendation/")
  16. class MarketService:
  17. def __init__(self, root: Path, repository: RDFRepository):
  18. self.root = root
  19. self.repository = repository
  20. self.config = json.loads(
  21. (root / "config" / "market-strategies.json").read_text(encoding="utf-8")
  22. )
  23. self.runs: dict[str, dict] = {}
  24. self.audit_graph = Graph()
  25. self._lock = RLock()
  26. def customers(self) -> list[dict]:
  27. rows = self.repository.query_file(
  28. self.root / "queries" / "market-customers.rq"
  29. )["rows"]
  30. return [
  31. {
  32. "id": row["customer"],
  33. "name": row["customerName"],
  34. "industry": row.get("industry", ""),
  35. "preferred_route": row.get("routeName", ""),
  36. "repeat_purchase_count": _as_int(row.get("repeatPurchaseCount")),
  37. "price_sensitivity": _as_float(row.get("customerPriceSensitivity"), 0.5),
  38. "time_sensitivity": _as_float(row.get("customerTimeSensitivity"), 0.5),
  39. "reliability_sensitivity": _as_float(
  40. row.get("customerReliabilitySensitivity"), 0.5
  41. ),
  42. }
  43. for row in rows
  44. ]
  45. def form_options(self) -> dict:
  46. candidates = self._candidate_records()
  47. context_rows = self.repository.query_file(
  48. self.root / "queries" / "market-context.rq"
  49. )["rows"]
  50. configured_routes = {
  51. (row.get("origin", ""), row.get("destination", ""))
  52. for row in context_rows
  53. if row.get("kind") == "benchmark" and row.get("origin") and row.get("destination")
  54. }
  55. origins = sorted({origin for origin, _ in configured_routes})
  56. destinations = sorted({destination for _, destination in configured_routes})
  57. route_types = sorted({
  58. item.route_type
  59. for item in candidates
  60. if item.route_type and (item.origin, item.destination) in configured_routes
  61. })
  62. benchmark_by_route = {
  63. (row.get("origin", ""), row.get("destination", "")): _as_float(
  64. row.get("amount")
  65. )
  66. for row in context_rows
  67. if row.get("kind") == "benchmark"
  68. }
  69. routes = []
  70. for origin, destination in sorted(configured_routes):
  71. route_candidates = [
  72. item
  73. for item in candidates
  74. if item.origin == origin
  75. and item.destination == destination
  76. and item.departure_date
  77. and item.valid_to
  78. and item.valid_to >= date(2026, 7, 20)
  79. ]
  80. departures = [item.departure_date for item in route_candidates]
  81. transit_days = [item.transit_days for item in route_candidates]
  82. routes.append({
  83. "origin": origin,
  84. "destination": destination,
  85. "route_types": sorted({
  86. item.route_type for item in route_candidates if item.route_type
  87. }),
  88. "target_amount": benchmark_by_route.get((origin, destination), 0),
  89. "requested_departure_date": (
  90. min(departures).isoformat() if departures else "2026-07-27"
  91. ),
  92. "max_transit_days": max(transit_days) if transit_days else 30,
  93. })
  94. return {
  95. "customers": self.customers(),
  96. "origins": origins,
  97. "destinations": destinations,
  98. "route_types": route_types,
  99. "routes": routes,
  100. "defaults": InquiryRequest(customer="https://gesli.example/sample#Haier").model_dump(
  101. mode="json"
  102. ),
  103. }
  104. def recommend(self, inquiry: InquiryRequest) -> dict:
  105. customer_row = self._find_customer(inquiry.customer)
  106. customer = self._customer_signals(customer_row)
  107. component_cost, component_names = self._component_costs(
  108. inquiry.evaluation_date, inquiry.origin
  109. )
  110. component_product_codes = {
  111. code
  112. for codes in self.config["component_product_codes_by_origin"].values()
  113. for code in codes
  114. }
  115. route_candidates = [
  116. candidate
  117. for candidate in self._candidate_records()
  118. if candidate.origin == inquiry.origin
  119. and candidate.destination == inquiry.destination
  120. and candidate.currency == "CNY"
  121. and candidate.product_code not in component_product_codes
  122. ]
  123. feasible: list[CandidateRecord] = []
  124. rejected: list[dict] = []
  125. for candidate in route_candidates:
  126. reasons = rejection_reasons(candidate, inquiry)
  127. if reasons:
  128. rejected.append(self._rejected_payload(candidate, reasons))
  129. else:
  130. feasible.append(candidate)
  131. if not feasible:
  132. raise ValueError("没有满足当前硬条件的供应商产品和班期")
  133. benchmark, strategy = self._market_context(inquiry)
  134. scored, weights = score_candidates(feasible, inquiry, customer)
  135. candidates: list[dict] = []
  136. for item in scored:
  137. candidate: CandidateRecord = item.pop("candidate")
  138. total_supplier_cost = candidate.supplier_cost + component_cost
  139. prices = build_prices(
  140. benchmark_amount=benchmark["amount"],
  141. strategy_rate=strategy["adjustment_rate"],
  142. solution_type=item["solution_type"],
  143. customer=customer,
  144. salesperson_adjustment=inquiry.salesperson_adjustment,
  145. supplier_cost=total_supplier_cost,
  146. config=self.config,
  147. )
  148. payload = self._candidate_payload(candidate, item, prices, component_names)
  149. candidates.append(payload)
  150. top = candidates[0]
  151. score_gap = top["total_score"] - candidates[1]["total_score"] if len(candidates) > 1 else 10
  152. confidence = round(min(0.98, 0.78 + max(0, score_gap) / 100), 2)
  153. top["confidence_score"] = confidence
  154. run_id = f"rec-{uuid4().hex[:12]}"
  155. generated_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
  156. result = {
  157. "run_id": run_id,
  158. "generated_at": generated_at,
  159. "normalized_inquiry": {
  160. **inquiry.model_dump(mode="json"),
  161. "customer_name": customer_row["customerName"],
  162. "route": f"{inquiry.origin} → {inquiry.destination}",
  163. "dangerous_goods_label": "危险品" if inquiry.dangerous_goods else "非危险品",
  164. },
  165. "pipeline": {
  166. "discovered": len(route_candidates),
  167. "rejected": len(rejected),
  168. "feasible": len(feasible),
  169. "generated_solutions": len(candidates),
  170. },
  171. "recommendation": {
  172. "candidate_id": top["id"],
  173. "solution_type": top["solution_type"],
  174. "product_name": top["product_name"],
  175. "supplier_name": top["supplier_name"],
  176. "final_quote_amount": top["pricing"]["final_quote_amount"],
  177. "currency": top["pricing"]["currency"],
  178. "confidence_score": confidence,
  179. "summary": self._recommendation_summary(top, strategy, customer_row),
  180. },
  181. "market_context": {
  182. "benchmark_amount": benchmark["amount"],
  183. "currency": benchmark["currency"],
  184. "strategy_name": strategy["name"],
  185. "strategy_objective": strategy["objective"],
  186. "strategy_adjustment_rate": strategy["adjustment_rate"],
  187. },
  188. "weights": weights,
  189. "candidates": candidates,
  190. "rejected_candidates": rejected,
  191. }
  192. self._store_run(run_id, result, inquiry, strategy)
  193. return result
  194. def get_run(self, run_id: str) -> dict | None:
  195. with self._lock:
  196. return self.runs.get(run_id)
  197. def reset_runtime(self) -> None:
  198. with self._lock:
  199. self.runs.clear()
  200. self.audit_graph = Graph()
  201. def get_audit(self, run_id: str) -> dict | None:
  202. run = self.get_run(run_id)
  203. if not run:
  204. return None
  205. run_uri = RUN[run_id]
  206. with self._lock:
  207. triples = list(self.audit_graph.triples((run_uri, None, None)))
  208. evaluations = [
  209. evaluation
  210. for _, _, evaluation in self.audit_graph.triples(
  211. (run_uri, GESLI.hasCandidateEvaluation, None)
  212. )
  213. ]
  214. for evaluation in evaluations:
  215. triples.extend(self.audit_graph.triples((evaluation, None, None)))
  216. return {
  217. "run_id": run_id,
  218. "generated_at": run["generated_at"],
  219. "input": run["normalized_inquiry"],
  220. "weights": run["weights"],
  221. "market_context": run["market_context"],
  222. "candidate_count": len(run["candidates"]),
  223. "rejected_count": len(run["rejected_candidates"]),
  224. "rdf_assertions": len(triples),
  225. "sources": self.repository.loaded_file_names,
  226. "business_explanation": self._business_audit(run),
  227. }
  228. def _candidate_records(self) -> list[CandidateRecord]:
  229. rows = self.repository.query_file(
  230. self.root / "queries" / "market-candidates.rq"
  231. )["rows"]
  232. return [
  233. CandidateRecord(
  234. quote_uri=row["quote"],
  235. quote_no=row["quoteNo"],
  236. product_uri=row["product"],
  237. product_code=row.get("productCode", ""),
  238. product_name=row.get("productName", ""),
  239. brand_name=row.get("brandName", ""),
  240. transport_mode=row.get("transportMode", ""),
  241. offering_uri=row["offering"],
  242. offering_code=row.get("offeringCode", ""),
  243. supplier_uri=row["supplier"],
  244. supplier_name=row["supplierName"],
  245. schedule_uri=row.get("schedule", ""),
  246. schedule_code=row.get("scheduleCode", ""),
  247. route_type=row.get("routeType", ""),
  248. service_form=row.get("serviceForm", ""),
  249. origin=row.get("originLocation", ""),
  250. destination=row.get("destinationLocation", ""),
  251. currency=row["currency"],
  252. supplier_cost=_as_float(row.get("supplierCostAmount")),
  253. valid_from=_as_date(row.get("validFrom")),
  254. valid_to=_as_date(row.get("validTo")),
  255. departure_date=_as_date(row.get("estimatedDepartureTime")),
  256. arrival_date=_as_date(row.get("estimatedArrivalTime")),
  257. transit_days=_as_float(row.get("estimatedTransitDays")),
  258. booking_success_rate=_as_float(row.get("bookingSuccessRate"), 0.8),
  259. space_release_success_rate=_as_float(
  260. row.get("spaceReleaseSuccessRate"), 0.8
  261. ),
  262. etd_on_time_rate=_as_float(row.get("etdAtdOnTimeRate"), 0.85),
  263. eta_on_time_rate=_as_float(row.get("etaAtaOnTimeRate"), 0.85),
  264. supplier_active=_as_bool(row.get("supplierActive"), True),
  265. capacity_available=_as_bool(row.get("capacityAvailable"), True),
  266. supports_dangerous_goods=_as_bool(
  267. row.get("supportsDangerousGoods"), False
  268. ),
  269. dangerous_goods_only=_as_bool(row.get("dangerousGoodsOnly"), False),
  270. agency_level=row.get("agencyLevel", ""),
  271. offering_channel=row.get("offeringChannel", ""),
  272. capacity_commitment=row.get("capacityCommitment", ""),
  273. spot_space_status=row.get("spotSpaceStatus") or "需确认",
  274. spot_space_teu=_as_float(row.get("spotSpaceTeu")),
  275. solution_assurance=_as_float(row.get("solutionAssuranceScore"), 0.72),
  276. service_support_score=_as_float(row.get("serviceSupportScore"), 0.72),
  277. relationship_tier=row.get("relationshipTier") or "基础合作",
  278. relationship_strength=_as_float(row.get("relationshipStrength"), 0.60),
  279. low_price_access_probability=_as_float(
  280. row.get("lowPriceAccessProbability"), 0.50
  281. ),
  282. priority_space_probability=_as_float(
  283. row.get("prioritySpaceProbability"), 0.60
  284. ),
  285. relationship_evidence=(
  286. row.get("relationshipEvidence") or "按标准合作机制执行"
  287. ),
  288. )
  289. for row in rows
  290. ]
  291. def _find_customer(self, customer_id: str) -> dict:
  292. rows = self.repository.query_file(
  293. self.root / "queries" / "market-customers.rq"
  294. )["rows"]
  295. for row in rows:
  296. if row["customer"] == customer_id or row["customerName"] == customer_id:
  297. return row
  298. raise ValueError("客户不存在或没有可用画像")
  299. @staticmethod
  300. def _customer_signals(row: dict) -> CustomerSignals:
  301. service_requirement = ";".join(
  302. value
  303. for value in (
  304. row.get("notificationRequirement", ""),
  305. row.get("documentRequirement", ""),
  306. row.get("exceptionResponseRequirement", ""),
  307. )
  308. if value
  309. )
  310. return CustomerSignals(
  311. price_sensitivity=_as_float(row.get("customerPriceSensitivity"), 0.5),
  312. time_sensitivity=_as_float(row.get("customerTimeSensitivity"), 0.5),
  313. reliability_sensitivity=_as_float(
  314. row.get("customerReliabilitySensitivity"), 0.5
  315. ),
  316. repeat_purchase_count=_as_int(row.get("repeatPurchaseCount")),
  317. overdue_days=_as_int(row.get("overdueDays")),
  318. service_requirement=service_requirement,
  319. )
  320. def _component_costs(
  321. self, evaluation_date: date, origin: str
  322. ) -> tuple[float, list[str]]:
  323. candidates = self._candidate_records()
  324. total = 0.0
  325. names: list[str] = []
  326. product_codes = self.config["component_product_codes_by_origin"].get(
  327. origin,
  328. self.config["component_product_codes_by_origin"].get("__default__", []),
  329. )
  330. for product_code in product_codes:
  331. valid = [
  332. item
  333. for item in candidates
  334. if item.product_code == product_code
  335. and (not item.valid_from or item.valid_from <= evaluation_date)
  336. and (not item.valid_to or evaluation_date <= item.valid_to)
  337. ]
  338. if valid:
  339. selected = min(valid, key=lambda item: item.supplier_cost)
  340. total += selected.supplier_cost
  341. names.append(selected.product_name)
  342. return total, names
  343. def _market_context(self, inquiry: InquiryRequest) -> tuple[dict, dict]:
  344. rows = self.repository.query_file(
  345. self.root / "queries" / "market-context.rq"
  346. )["rows"]
  347. applicable = [
  348. row
  349. for row in rows
  350. if row.get("origin") == inquiry.origin
  351. and row.get("destination") == inquiry.destination
  352. and _within(inquiry.evaluation_date, row.get("validFrom"), row.get("validTo"))
  353. ]
  354. benchmark_row = next((row for row in applicable if row["kind"] == "benchmark"), None)
  355. strategy_row = next((row for row in applicable if row["kind"] == "strategy"), None)
  356. if not benchmark_row or not strategy_row:
  357. raise ValueError("当前路线缺少有效的市场基准价格或启用策略")
  358. return (
  359. {
  360. "uri": benchmark_row["resource"],
  361. "amount": _as_float(benchmark_row["amount"]),
  362. "currency": benchmark_row["currency"],
  363. },
  364. {
  365. "uri": strategy_row["resource"],
  366. "name": strategy_row["strategyName"],
  367. "objective": strategy_row["strategyObjective"],
  368. "adjustment_rate": _as_float(strategy_row["strategyAdjustmentRate"]),
  369. },
  370. )
  371. @staticmethod
  372. def _candidate_payload(
  373. candidate: CandidateRecord,
  374. scores: dict,
  375. prices: dict,
  376. component_names: list[str],
  377. ) -> dict:
  378. reliability = round(
  379. (candidate.booking_success_rate + candidate.space_release_success_rate) / 2,
  380. 2,
  381. )
  382. reasons = [
  383. f"我方与{candidate.supplier_name}为{candidate.relationship_tier}:{candidate.relationship_evidence}",
  384. _spot_space_summary(candidate.spot_space_status, candidate.spot_space_teu),
  385. f"低价获取概率{candidate.low_price_access_probability:.0%},优先舱位概率{candidate.priority_space_probability:.0%}",
  386. f"供应商订舱与放舱综合成功率{reliability:.0%}",
  387. f"预计运输时间{candidate.transit_days:g}天",
  388. f"{candidate.route_type or '当前'}路由,{candidate.capacity_commitment or '运力已确认'}",
  389. ]
  390. focus_reasons = {
  391. "方案优选": "方案保障、供应商合作与履约表现综合最优",
  392. "关系优选": "船公司或订舱代理的合作优势最突出",
  393. "价格参考": "当前可执行候选中采购价格最具参考性",
  394. "时效参考": "当前可执行候选中预计运输时间最短",
  395. }
  396. reasons.insert(0, focus_reasons.get(scores["solution_type"], "可执行的备选运输方案"))
  397. return {
  398. "id": candidate.quote_uri,
  399. "quote_no": candidate.quote_no,
  400. "solution_type": scores["solution_type"],
  401. "rank": scores["rank"],
  402. "brand_name": candidate.brand_name,
  403. "product_name": candidate.product_name,
  404. "supplier_name": candidate.supplier_name,
  405. "offering_channel": candidate.offering_channel,
  406. "schedule_code": candidate.schedule_code,
  407. "departure_date": candidate.departure_date.isoformat() if candidate.departure_date else "",
  408. "arrival_date": candidate.arrival_date.isoformat() if candidate.arrival_date else "",
  409. "transit_days": candidate.transit_days,
  410. "route_type": candidate.route_type,
  411. "capacity_commitment": candidate.capacity_commitment,
  412. "components": [*component_names, candidate.product_name],
  413. "reliability_rate": reliability,
  414. "price_score": scores["price_score"],
  415. "transit_score": scores["transit_score"],
  416. "supplier_execution_score": scores["supplier_execution_score"],
  417. "customer_fit_score": scores["customer_fit_score"],
  418. "solution_assurance_score": scores["solution_assurance_score"],
  419. "relationship_advantage_score": scores["relationship_advantage_score"],
  420. "spot_space_score": scores["spot_space_score"],
  421. "total_score": scores["total_score"],
  422. "confidence_score": None,
  423. "relationship": {
  424. "supplier_name": candidate.supplier_name,
  425. "supplier_channel": candidate.offering_channel,
  426. "tier": candidate.relationship_tier,
  427. "strength": candidate.relationship_strength,
  428. "low_price_access_probability": candidate.low_price_access_probability,
  429. "priority_space_probability": candidate.priority_space_probability,
  430. "evidence": candidate.relationship_evidence,
  431. },
  432. "spot_space": {
  433. "status": candidate.spot_space_status,
  434. "teu": candidate.spot_space_teu,
  435. "summary": _spot_space_summary(
  436. candidate.spot_space_status, candidate.spot_space_teu
  437. ),
  438. },
  439. "reasons": reasons,
  440. "pricing": prices,
  441. }
  442. @staticmethod
  443. def _rejected_payload(candidate: CandidateRecord, reasons: list[str]) -> dict:
  444. return {
  445. "id": candidate.quote_uri,
  446. "quote_no": candidate.quote_no,
  447. "product_name": candidate.product_name,
  448. "supplier_name": candidate.supplier_name,
  449. "schedule_code": candidate.schedule_code,
  450. "reasons": reasons,
  451. }
  452. @staticmethod
  453. def _recommendation_summary(
  454. candidate: dict, strategy: dict, customer_row: dict
  455. ) -> str:
  456. return (
  457. f"为{customer_row['customerName']}推荐{candidate['solution_type']}:"
  458. f"{candidate['supplier_name']}的{candidate['product_name']}。"
  459. f"综合得分{candidate['total_score']:.1f},采用“{strategy['name']}”,"
  460. f"当前{candidate['spot_space']['summary']},"
  461. f"在方案保障、供应商合作、现舱与履约可靠性之间取得当前最优平衡。"
  462. )
  463. @staticmethod
  464. def _business_audit(run: dict) -> dict:
  465. top = run["candidates"][0]
  466. alternatives = []
  467. for candidate in run["candidates"][1:]:
  468. alternatives.append({
  469. "title": f"{candidate['solution_type']}:{candidate['supplier_name']}",
  470. "detail": (
  471. f"{candidate['spot_space']['summary']};"
  472. f"我方与该供应商为{candidate['relationship']['tier']};"
  473. f"预计{candidate['transit_days']:g}天到达。"
  474. ),
  475. })
  476. rejected = [
  477. f"{candidate['supplier_name']}:{';'.join(candidate['reasons'])}"
  478. for candidate in run["rejected_candidates"][:3]
  479. ]
  480. return {
  481. "decision": (
  482. f"本次为{run['normalized_inquiry']['customer_name']}的"
  483. f"{run['normalized_inquiry']['route']}询价,在"
  484. f"{len(run['candidates'])}个可执行方案中选择了{top['supplier_name']}。"
  485. ),
  486. "why_recommended": [
  487. f"{top['spot_space']['summary']},可直接支持本次订舱安排。",
  488. (
  489. f"我方与{top['supplier_name']}为{top['relationship']['tier']},"
  490. f"{top['relationship']['evidence']}。"
  491. ),
  492. (
  493. f"方案为{top['route_type']},预计{top['transit_days']:g}天,"
  494. f"方案保障得分{top['solution_assurance_score']}分。"
  495. ),
  496. f"建议向客户报价{top['pricing']['final_quote_amount']:,.0f} {top['pricing']['currency']}。",
  497. ],
  498. "alternatives": alternatives,
  499. "rejected": rejected,
  500. "selection_basis": "优先看方案保障、与船公司或订舱代理的合作深度和现舱;再比较履约、客户需求、时效与价格。价格用于利润校验和横向比较,但不单独决定首选。",
  501. }
  502. def _store_run(
  503. self, run_id: str, result: dict, inquiry: InquiryRequest, strategy: dict
  504. ) -> None:
  505. run_uri = RUN[run_id]
  506. generated_at = Literal(result["generated_at"], datatype=XSD.dateTime)
  507. with self._lock:
  508. self.runs[run_id] = result
  509. self.audit_graph.add((run_uri, RDF.type, GESLI.RecommendationRun))
  510. self.audit_graph.add((run_uri, GESLI.generatedAt, generated_at))
  511. self.audit_graph.add((run_uri, GESLI.usesMarketStrategy, URIRef(strategy["uri"])))
  512. self.audit_graph.add((run_uri, GESLI.recommendationReason, Literal(result["recommendation"]["summary"])))
  513. customer_uri = URIRef(inquiry.customer)
  514. self.audit_graph.add((run_uri, GESLI.quoteForCustomer, customer_uri))
  515. for candidate in result["candidates"]:
  516. evaluation_uri = RUN[f"{run_id}/candidate/{candidate['rank']}"]
  517. self.audit_graph.add((evaluation_uri, RDF.type, GESLI.CandidateEvaluation))
  518. self.audit_graph.add((run_uri, GESLI.hasCandidateEvaluation, evaluation_uri))
  519. self.audit_graph.add((evaluation_uri, GESLI.evaluatedQuote, URIRef(candidate["id"])))
  520. self.audit_graph.add((evaluation_uri, GESLI.recommendationRank, Literal(candidate["rank"])))
  521. self.audit_graph.add((evaluation_uri, GESLI.totalScore, Literal(candidate["total_score"])))
  522. self.audit_graph.add((
  523. evaluation_uri,
  524. GESLI.solutionAssuranceEvaluationScore,
  525. Literal(candidate["solution_assurance_score"]),
  526. ))
  527. self.audit_graph.add((
  528. evaluation_uri,
  529. GESLI.relationshipAdvantageScore,
  530. Literal(candidate["relationship_advantage_score"]),
  531. ))
  532. self.audit_graph.add((
  533. evaluation_uri,
  534. GESLI.spotSpaceScore,
  535. Literal(candidate["spot_space_score"]),
  536. ))
  537. top_evaluation = RUN[f"{run_id}/candidate/1"]
  538. self.audit_graph.add((run_uri, GESLI.recommendedEvaluation, top_evaluation))
  539. def _as_float(value: str | None, default: float = 0.0) -> float:
  540. try:
  541. return float(value) if value not in (None, "") else default
  542. except (TypeError, ValueError):
  543. return default
  544. def _spot_space_summary(status: str, teu: float) -> str:
  545. status_label = status if status.startswith("现舱") else f"现舱{status}"
  546. if teu > 0:
  547. return f"{status_label},当前可订{teu:g} TEU"
  548. return f"{status_label},需向供应商确认"
  549. def _as_int(value: str | None, default: int = 0) -> int:
  550. try:
  551. return int(value) if value not in (None, "") else default
  552. except (TypeError, ValueError):
  553. return default
  554. def _as_bool(value: str | None, default: bool = False) -> bool:
  555. if value in (None, ""):
  556. return default
  557. return str(value).lower() in {"true", "1", "yes"}
  558. def _as_date(value: str | None) -> date | None:
  559. if not value:
  560. return None
  561. try:
  562. return date.fromisoformat(value[:10])
  563. except ValueError:
  564. return None
  565. def _within(current: date, start: str | None, end: str | None) -> bool:
  566. start_date = _as_date(start)
  567. end_date = _as_date(end)
  568. return (not start_date or start_date <= current) and (not end_date or current <= end_date)