dongwu 1 vecka sedan
förälder
incheckning
4ea2872c16

+ 26 - 29
README.md

@@ -1,6 +1,8 @@
-# 格士立全球运输本体开发环境 v3
+# 格士立全球运输与AI市场中枢 Demo v5
 
-这是格士立全球运输与自主货代运营本体的开发环境。青岛出口继续作为第一组测试样例,但本体本身不再受青岛、出口或单一运输方式限制。
+这是格士立全球运输、自主货代运营与AI市场决策的本地开发环境。青岛出口继续作为第一组测试样例,但本体本身不受青岛、出口或单一运输方式限制。
+
+默认采用 local 模式:FastAPI 启动时由 `rdflib` 直接加载本体和样例数据,不依赖 Docker、Fuseki 或手工导入。
 
 你打开这个文件夹后,应该看到:
 
@@ -18,7 +20,10 @@ docs/
 ## 这个环境有什么
 
 - `ontology/global-shipment.ttl`:全球运输与自主货代运营本体
+- `ontology/ai-market-demo-extension.ttl`:询价约束、候选评估与推荐审计扩展
 - `data/qingdao-export-sample.ttl`:青岛出口样例数据
+- `data/ai-market-demo.ttl`:同路线多产品、多供应商和有效/失效报价场景
+- `data/ai-market-scenarios.ttl`:汉堡海运、法兰克福空运、危险品和新增客户画像
 - `queries/order-status.rq`:查询订单当前状态
 - `queries/risk-orders.rq`:查询有风险的订单
 - `queries/operation-flow.rq`:查询青岛出口标准流程
@@ -39,7 +44,9 @@ docs/
 - `docs/customer-supplier-profile-mapping.md`:客户画像与平台供应商画像对应关系
 - `docs/product-supplier-market-model.md`:产品、供应商、组合方案和AI市场推荐价格模型
 - `app/main.py`:简单接口服务
-- `tests/basic_check.py`:基础检查脚本
+- `app/market/`:过滤、评分、定价和解释型推荐引擎
+- `app/mock_data.py`:本地数据目录、动态 Mock 生成和内存重置
+- `tests/`:本体、查询、决策和回归测试
 - `docker-compose.yml`:启动 Fuseki 本体数据库
 
 ## 第一步检查
@@ -58,35 +65,22 @@ python3 tests/basic_check.py
 
 说明文件是完整的。
 
-## 第二步启动本体数据库
-
-如果你的电脑有 Docker,在 Cursor 终端里运行:
+## 第二步运行完整测试
 
 ```bash
-docker compose up -d
-```
-
-然后打开:
-
-```text
-http://localhost:3030
-```
-
-账号密码:
-
-```text
-admin / admin
+source .venv/bin/activate
+python -m unittest discover -s tests
 ```
 
-## 启动操作员前端
+## 第三步启动本地 Demo
 
-确认 Fuseki 已经运行,并且 `gesli` 数据集已经依次导入 `ontology/global-shipment.ttl` 和 `data/qingdao-export-sample.ttl` 后,在本目录运行:
+首次运行时在本目录执行:
 
 ```bash
 python3 -m venv .venv
 source .venv/bin/activate
 pip install -r requirements.txt
-uvicorn app.main:app --reload
+python -m uvicorn app.main:app --reload
 ```
 
 打开:
@@ -95,17 +89,20 @@ uvicorn app.main:app --reload
 http://localhost:8000
 ```
 
-前端会显示
+默认进入 AI 市场中枢,可提交客户询价并查看
 
 ```text
-订单状态
-风险订单
-标准流程
-节点时间状态
-订单关键时限
-异常影响
+硬条件过滤过程
+经济、均衡和时效候选方案
+客户、价格、时效、供应商和产品策略评分
+AI市场推荐价格、客户调整和最终报价
+淘汰原因与决策审计
 ```
 
+顶部可以切换到 Mock 数据台和原有履约操作台。Mock 数据台可以查看客户、路线、产品、供应商、报价和策略,也可以向当前进程临时添加供应商报价和客户画像;动态数据不会写入 TTL,服务重启或点击“重置内存数据”后消失。
+
+Fuseki 和 `docker-compose.yml` 继续保留,仅用于后续远程数据服务实验,不是本地 Demo 的运行前置条件。
+
 ## 给你的判断标准
 
 如果 Cursor 左边看不到 `README.md`、`ontology`、`data`、`queries`,说明打开的不是这个文件夹。

+ 71 - 34
app/main.py

@@ -1,25 +1,35 @@
 from pathlib import Path
-from urllib.parse import urlencode
-from urllib.request import Request, urlopen
-import json
 
 from fastapi import FastAPI, HTTPException
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
 
-app = FastAPI(title="格士立青岛出口本体接口")
+from app.market import MarketService
+from app.market.models import InquiryRequest
+from app.mock_data import LocalMockDataService, MockBatchRequest
+from app.repository import RDFRepository
+
+app = FastAPI(title="格士立全球运输与AI市场接口")
 
 ROOT = Path(__file__).resolve().parents[1]
 QUERIES = ROOT / "queries"
 STATIC = ROOT / "app" / "static"
-FUSEKI_ENDPOINT = "http://localhost:3030/gesli/sparql"
+repository = RDFRepository(ROOT)
+market_service = MarketService(ROOT, repository)
+mock_data_service = LocalMockDataService(ROOT, repository, market_service)
 
 app.mount("/static", StaticFiles(directory=STATIC), name="static")
 
 
 @app.get("/health")
 def health():
-    return {"status": "ok"}
+    return {
+        "status": "ok",
+        "data_backend": "local",
+        "triple_count": repository.triple_count,
+        "in_memory_triple_count": repository.in_memory_triple_count,
+        "loaded_files": repository.loaded_file_names,
+    }
 
 
 @app.get("/")
@@ -57,39 +67,66 @@ def exception_map():
     return query_file("exception-risk-map.rq")
 
 
+@app.get("/api/market/customers")
+def market_customers():
+    return {"rows": market_service.customers()}
+
+
+@app.get("/api/market/form-options")
+def market_form_options():
+    return market_service.form_options()
+
+
+@app.post("/api/market/recommend")
+def market_recommend(inquiry: InquiryRequest):
+    try:
+        return market_service.recommend(inquiry)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@app.get("/api/market/recommendations/{run_id}")
+def market_recommendation(run_id: str):
+    result = market_service.get_run(run_id)
+    if not result:
+        raise HTTPException(status_code=404, detail="推荐任务不存在或服务已经重启")
+    return result
+
+
+@app.get("/api/market/recommendations/{run_id}/audit")
+def market_recommendation_audit(run_id: str):
+    result = market_service.get_audit(run_id)
+    if not result:
+        raise HTTPException(status_code=404, detail="推荐任务审计记录不存在")
+    return result
+
+
+@app.get("/api/mock/catalog")
+def mock_catalog():
+    return mock_data_service.catalog()
+
+
+@app.post("/api/mock/generate")
+def mock_generate(request: MockBatchRequest):
+    try:
+        return mock_data_service.generate(request)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
+@app.post("/api/mock/reset")
+def mock_reset():
+    return mock_data_service.reset()
+
+
 def query_file(filename: str):
     path = QUERIES / filename
     if not path.exists():
         raise HTTPException(status_code=404, detail=f"Query file not found: {filename}")
-    return run_sparql(path.read_text(encoding="utf-8"))
-
-
-def run_sparql(query: str):
-    data = urlencode({"query": query}).encode("utf-8")
-    request = Request(
-        FUSEKI_ENDPOINT,
-        data=data,
-        headers={
-            "Accept": "application/sparql-results+json",
-            "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
-        },
-        method="POST",
-    )
-
     try:
-        with urlopen(request, timeout=10) as response:
-            payload = json.loads(response.read().decode("utf-8"))
+        return repository.query_file(path)
     except Exception as exc:
         raise HTTPException(
-            status_code=502,
-            detail="无法连接 Fuseki。请确认 Docker 和 http://localhost:3030/gesli/sparql 正常运行。",
+            status_code=500,
+            detail=f"本地RDF查询执行失败: {filename}",
         ) from exc
-
-    variables = payload.get("head", {}).get("vars", [])
-    rows = []
-    for binding in payload.get("results", {}).get("bindings", []):
-        rows.append({
-            variable: binding.get(variable, {}).get("value", "")
-            for variable in variables
-        })
-    return {"rows": rows}

+ 4 - 0
app/market/__init__.py

@@ -0,0 +1,4 @@
+from app.market.service import MarketService
+
+__all__ = ["MarketService"]
+

+ 32 - 0
app/market/filters.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from app.market.models import CandidateRecord, InquiryRequest
+
+
+def rejection_reasons(candidate: CandidateRecord, inquiry: InquiryRequest) -> list[str]:
+    reasons: list[str] = []
+
+    if candidate.origin != inquiry.origin or candidate.destination != inquiry.destination:
+        reasons.append("起运地或目的地不匹配")
+    if not candidate.supplier_active:
+        reasons.append("供应商不是有效合作方")
+    if not candidate.capacity_available:
+        reasons.append("当前班期没有可用运力")
+    if candidate.valid_from and inquiry.evaluation_date < candidate.valid_from:
+        reasons.append(f"报价尚未生效({candidate.valid_from.isoformat()}起)")
+    if candidate.valid_to and inquiry.evaluation_date > candidate.valid_to:
+        reasons.append(f"报价已于{candidate.valid_to.isoformat()}失效")
+    if not candidate.departure_date:
+        reasons.append("报价没有关联可执行班期")
+    elif candidate.departure_date < inquiry.requested_departure_date:
+        reasons.append(f"班期{candidate.departure_date.isoformat()}早于要求起运日")
+    if candidate.transit_days > inquiry.max_transit_days:
+        reasons.append(
+            f"预计{candidate.transit_days:g}天,超过客户{inquiry.max_transit_days:g}天上限"
+        )
+    if inquiry.dangerous_goods and not candidate.supports_dangerous_goods:
+        reasons.append("该供应商产品关系不支持危险品")
+    if not inquiry.dangerous_goods and candidate.dangerous_goods_only:
+        reasons.append("危险品专用产品不适用于普通货物")
+
+    return reasons

+ 59 - 0
app/market/models.py

@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from datetime import date
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class InquiryRequest(BaseModel):
+    customer: str
+    origin: str = "青岛港"
+    destination: str = "洛杉矶港"
+    cargo_name: str = "家电"
+    cargo_volume: float = Field(default=2, gt=0)
+    evaluation_date: date = date(2026, 7, 20)
+    requested_departure_date: date = date(2026, 7, 27)
+    max_transit_days: float = Field(default=21, gt=0)
+    target_amount: float = Field(default=54000, gt=0)
+    preferred_route_type: str = "直航"
+    dangerous_goods: bool = False
+    salesperson_adjustment: float = 0
+
+
+class CandidateRecord(BaseModel):
+    quote_uri: str
+    quote_no: str
+    product_uri: str
+    product_code: str
+    product_name: str
+    brand_name: str = ""
+    transport_mode: str = ""
+    offering_uri: str
+    offering_code: str = ""
+    supplier_uri: str
+    supplier_name: str
+    schedule_uri: str = ""
+    schedule_code: str = ""
+    route_type: str = ""
+    service_form: str = ""
+    origin: str = ""
+    destination: str = ""
+    currency: str
+    supplier_cost: float
+    valid_from: Optional[date] = None
+    valid_to: Optional[date] = None
+    departure_date: Optional[date] = None
+    arrival_date: Optional[date] = None
+    transit_days: float = 0
+    booking_success_rate: float = 0.8
+    space_release_success_rate: float = 0.8
+    etd_on_time_rate: float = 0.85
+    eta_on_time_rate: float = 0.85
+    supplier_active: bool = True
+    capacity_available: bool = True
+    supports_dangerous_goods: bool = False
+    dangerous_goods_only: bool = False
+    agency_level: str = ""
+    offering_channel: str = ""
+    capacity_commitment: str = ""

+ 70 - 0
app/market/pricing.py

@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from app.market.scoring import CustomerSignals
+
+
+def round_amount(value: float, unit: int) -> float:
+    return float(round(value / unit) * unit)
+
+
+def customer_adjustment_rate(customer: CustomerSignals) -> tuple[float, list[str]]:
+    rate = 0.0
+    reasons: list[str] = []
+    if customer.repeat_purchase_count >= 15:
+        rate -= 0.005
+        reasons.append("高复购客户优惠0.5%")
+    if customer.overdue_days > 0:
+        rate += 0.003
+        reasons.append("历史逾期风险调整0.3%")
+    if customer.reliability_sensitivity >= 0.85:
+        rate += 0.008
+        reasons.append("高服务可靠性要求溢价0.8%")
+    if customer.price_sensitivity >= 0.75:
+        rate -= 0.005
+        reasons.append("价格敏感客户优惠0.5%")
+    return rate, reasons
+
+
+def build_prices(
+    benchmark_amount: float,
+    strategy_rate: float,
+    solution_type: str,
+    customer: CustomerSignals,
+    salesperson_adjustment: float,
+    supplier_cost: float,
+    config: dict,
+) -> dict:
+    rounding = int(config["quote_rounding"])
+    base_recommended = round_amount(benchmark_amount * (1 + strategy_rate), rounding)
+    solution_delta = config["solution_price_adjustments"].get(solution_type, 0)
+    recommended = base_recommended + solution_delta
+
+    adjustment_rate, adjustment_reasons = customer_adjustment_rate(customer)
+    customer_adjustment = round_amount(recommended * adjustment_rate, rounding)
+    final_quote = recommended + customer_adjustment + salesperson_adjustment
+    allowed_lower = recommended - config["allowed_lower_delta"]
+    allowed_upper = recommended + config["allowed_upper_delta"]
+
+    gross_margin_rate = (final_quote - supplier_cost) / final_quote if final_quote else 0
+    if gross_margin_rate >= 0.12:
+        cost_risk = "利润安全"
+    elif gross_margin_rate >= 0.08:
+        cost_risk = "利润偏低,建议复核"
+    else:
+        cost_risk = "低于利润底线,需要审批"
+
+    return {
+        "market_benchmark_amount": benchmark_amount,
+        "strategy_adjustment_rate": strategy_rate,
+        "ai_recommended_amount": recommended,
+        "allowed_quote_lower_amount": allowed_lower,
+        "allowed_quote_upper_amount": allowed_upper,
+        "customer_adjustment_rate": round(adjustment_rate, 4),
+        "customer_adjustment_amount": customer_adjustment,
+        "salesperson_adjustment_amount": salesperson_adjustment,
+        "final_quote_amount": final_quote,
+        "currency": "CNY",
+        "cost_risk_status": cost_risk,
+        "adjustment_reasons": adjustment_reasons,
+    }
+

+ 115 - 0
app/market/scoring.py

@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from app.market.models import CandidateRecord, InquiryRequest
+
+
+@dataclass(frozen=True)
+class CustomerSignals:
+    price_sensitivity: float
+    time_sensitivity: float
+    reliability_sensitivity: float
+    repeat_purchase_count: int
+    overdue_days: int
+    service_requirement: str
+
+
+def score_candidates(
+    candidates: list[CandidateRecord],
+    inquiry: InquiryRequest,
+    customer: CustomerSignals,
+) -> tuple[list[dict], dict[str, float]]:
+    if not candidates:
+        return [], {}
+
+    costs = [candidate.supplier_cost for candidate in candidates]
+    min_cost, max_cost = min(costs), max(costs)
+    min_transit = min(candidate.transit_days for candidate in candidates)
+
+    raw_weights = {
+        "price": 1 + customer.price_sensitivity,
+        "transit": 0.8 + customer.time_sensitivity,
+        "supplier": 1 + customer.reliability_sensitivity,
+        "customer": 0.9,
+        "strategy": 0.5,
+    }
+    weight_total = sum(raw_weights.values())
+    weights = {name: value / weight_total for name, value in raw_weights.items()}
+
+    scored: list[dict] = []
+    for candidate in candidates:
+        if max_cost == min_cost:
+            price_score = 100.0
+        else:
+            price_score = 100 - ((candidate.supplier_cost - min_cost) / (max_cost - min_cost) * 35)
+
+        transit_penalty = 12 if candidate.transport_mode == "空运" else 5
+        transit_score = max(
+            40.0,
+            100 - (candidate.transit_days - min_transit) * transit_penalty,
+        )
+        offering_reliability = (
+            candidate.booking_success_rate + candidate.space_release_success_rate
+        ) / 2
+        product_reliability = (
+            candidate.etd_on_time_rate + candidate.eta_on_time_rate
+        ) / 2
+        supplier_score = (offering_reliability * 0.7 + product_reliability * 0.3) * 100
+
+        is_brand_unit = "品牌本地分支机构" in candidate.agency_level
+        customer_score = 94.0 if is_brand_unit else 83.0
+        if candidate.route_type == inquiry.preferred_route_type:
+            customer_score += 4
+            if customer.time_sensitivity >= 0.85:
+                customer_score += 8
+        if customer.price_sensitivity >= 0.7 and not is_brand_unit:
+            customer_score += 7
+        if customer.reliability_sensitivity >= 0.85 and is_brand_unit:
+            customer_score += 2
+        customer_score = min(100.0, customer_score)
+
+        strategy_score = 92.0 if candidate.route_type == "直航" else 72.0
+        total_score = (
+            price_score * weights["price"]
+            + transit_score * weights["transit"]
+            + supplier_score * weights["supplier"]
+            + customer_score * weights["customer"]
+            + strategy_score * weights["strategy"]
+        )
+
+        scored.append({
+            "candidate": candidate,
+            "price_score": round(price_score, 1),
+            "transit_score": round(transit_score, 1),
+            "supplier_execution_score": round(supplier_score, 1),
+            "customer_fit_score": round(customer_score, 1),
+            "product_strategy_score": round(strategy_score, 1),
+            "total_score": round(total_score, 1),
+        })
+
+    assign_solution_types(scored)
+    scored.sort(key=lambda item: item["total_score"], reverse=True)
+    for rank, item in enumerate(scored, start=1):
+        item["rank"] = rank
+    return scored, {name: round(value, 4) for name, value in weights.items()}
+
+
+def assign_solution_types(scored: list[dict]) -> None:
+    economy = min(scored, key=lambda item: item["candidate"].supplier_cost)
+    fastest = min(
+        (item for item in scored if item is not economy),
+        key=lambda item: item["candidate"].transit_days,
+        default=economy,
+    )
+    balanced = max(
+        (item for item in scored if item not in (economy, fastest)),
+        key=lambda item: item["total_score"],
+        default=max(scored, key=lambda item: item["total_score"]),
+    )
+
+    for item in scored:
+        item["solution_type"] = "候选方案"
+    economy["solution_type"] = "经济方案"
+    fastest["solution_type"] = "时效方案"
+    balanced["solution_type"] = "均衡方案"

+ 502 - 0
app/market/service.py

@@ -0,0 +1,502 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+import json
+from pathlib import Path
+from threading import RLock
+from uuid import uuid4
+from zoneinfo import ZoneInfo
+
+from rdflib import Graph, Literal, Namespace, RDF, URIRef, XSD
+
+from app.market.filters import rejection_reasons
+from app.market.models import CandidateRecord, InquiryRequest
+from app.market.pricing import build_prices
+from app.market.scoring import CustomerSignals, score_candidates
+from app.repository import RDFRepository
+
+
+GESLI = Namespace("https://gesli.example/ontology#")
+RUN = Namespace("https://gesli.example/recommendation/")
+
+
+class MarketService:
+    def __init__(self, root: Path, repository: RDFRepository):
+        self.root = root
+        self.repository = repository
+        self.config = json.loads(
+            (root / "config" / "market-strategies.json").read_text(encoding="utf-8")
+        )
+        self.runs: dict[str, dict] = {}
+        self.audit_graph = Graph()
+        self._lock = RLock()
+
+    def customers(self) -> list[dict]:
+        rows = self.repository.query_file(
+            self.root / "queries" / "market-customers.rq"
+        )["rows"]
+        return [
+            {
+                "id": row["customer"],
+                "name": row["customerName"],
+                "industry": row.get("industry", ""),
+                "preferred_route": row.get("routeName", ""),
+                "repeat_purchase_count": _as_int(row.get("repeatPurchaseCount")),
+                "price_sensitivity": _as_float(row.get("customerPriceSensitivity"), 0.5),
+                "time_sensitivity": _as_float(row.get("customerTimeSensitivity"), 0.5),
+                "reliability_sensitivity": _as_float(
+                    row.get("customerReliabilitySensitivity"), 0.5
+                ),
+            }
+            for row in rows
+        ]
+
+    def form_options(self) -> dict:
+        candidates = self._candidate_records()
+        context_rows = self.repository.query_file(
+            self.root / "queries" / "market-context.rq"
+        )["rows"]
+        configured_routes = {
+            (row.get("origin", ""), row.get("destination", ""))
+            for row in context_rows
+            if row.get("kind") == "benchmark" and row.get("origin") and row.get("destination")
+        }
+        origins = sorted({origin for origin, _ in configured_routes})
+        destinations = sorted({destination for _, destination in configured_routes})
+        route_types = sorted({
+            item.route_type
+            for item in candidates
+            if item.route_type and (item.origin, item.destination) in configured_routes
+        })
+        benchmark_by_route = {
+            (row.get("origin", ""), row.get("destination", "")): _as_float(
+                row.get("amount")
+            )
+            for row in context_rows
+            if row.get("kind") == "benchmark"
+        }
+        routes = []
+        for origin, destination in sorted(configured_routes):
+            route_candidates = [
+                item
+                for item in candidates
+                if item.origin == origin
+                and item.destination == destination
+                and item.departure_date
+                and item.valid_to
+                and item.valid_to >= date(2026, 7, 20)
+            ]
+            departures = [item.departure_date for item in route_candidates]
+            transit_days = [item.transit_days for item in route_candidates]
+            routes.append({
+                "origin": origin,
+                "destination": destination,
+                "route_types": sorted({
+                    item.route_type for item in route_candidates if item.route_type
+                }),
+                "target_amount": benchmark_by_route.get((origin, destination), 0),
+                "requested_departure_date": (
+                    min(departures).isoformat() if departures else "2026-07-27"
+                ),
+                "max_transit_days": max(transit_days) if transit_days else 30,
+            })
+        return {
+            "customers": self.customers(),
+            "origins": origins,
+            "destinations": destinations,
+            "route_types": route_types,
+            "routes": routes,
+            "defaults": InquiryRequest(customer="https://gesli.example/sample#Haier").model_dump(
+                mode="json"
+            ),
+        }
+
+    def recommend(self, inquiry: InquiryRequest) -> dict:
+        customer_row = self._find_customer(inquiry.customer)
+        customer = self._customer_signals(customer_row)
+        component_cost, component_names = self._component_costs(
+            inquiry.evaluation_date, inquiry.origin
+        )
+        component_product_codes = {
+            code
+            for codes in self.config["component_product_codes_by_origin"].values()
+            for code in codes
+        }
+
+        route_candidates = [
+            candidate
+            for candidate in self._candidate_records()
+            if candidate.origin == inquiry.origin
+            and candidate.destination == inquiry.destination
+            and candidate.currency == "CNY"
+            and candidate.product_code not in component_product_codes
+        ]
+
+        feasible: list[CandidateRecord] = []
+        rejected: list[dict] = []
+        for candidate in route_candidates:
+            reasons = rejection_reasons(candidate, inquiry)
+            if reasons:
+                rejected.append(self._rejected_payload(candidate, reasons))
+            else:
+                feasible.append(candidate)
+
+        if not feasible:
+            raise ValueError("没有满足当前硬条件的供应商产品和班期")
+
+        benchmark, strategy = self._market_context(inquiry)
+        scored, weights = score_candidates(feasible, inquiry, customer)
+
+        candidates: list[dict] = []
+        for item in scored:
+            candidate: CandidateRecord = item.pop("candidate")
+            total_supplier_cost = candidate.supplier_cost + component_cost
+            prices = build_prices(
+                benchmark_amount=benchmark["amount"],
+                strategy_rate=strategy["adjustment_rate"],
+                solution_type=item["solution_type"],
+                customer=customer,
+                salesperson_adjustment=inquiry.salesperson_adjustment,
+                supplier_cost=total_supplier_cost,
+                config=self.config,
+            )
+            payload = self._candidate_payload(candidate, item, prices, component_names)
+            candidates.append(payload)
+
+        top = candidates[0]
+        score_gap = top["total_score"] - candidates[1]["total_score"] if len(candidates) > 1 else 10
+        confidence = round(min(0.98, 0.78 + max(0, score_gap) / 100), 2)
+        top["confidence_score"] = confidence
+
+        run_id = f"rec-{uuid4().hex[:12]}"
+        generated_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
+        result = {
+            "run_id": run_id,
+            "generated_at": generated_at,
+            "normalized_inquiry": {
+                **inquiry.model_dump(mode="json"),
+                "customer_name": customer_row["customerName"],
+                "route": f"{inquiry.origin} → {inquiry.destination}",
+                "dangerous_goods_label": "危险品" if inquiry.dangerous_goods else "非危险品",
+            },
+            "pipeline": {
+                "discovered": len(route_candidates),
+                "rejected": len(rejected),
+                "feasible": len(feasible),
+                "generated_solutions": len(candidates),
+            },
+            "recommendation": {
+                "candidate_id": top["id"],
+                "solution_type": top["solution_type"],
+                "product_name": top["product_name"],
+                "supplier_name": top["supplier_name"],
+                "final_quote_amount": top["pricing"]["final_quote_amount"],
+                "currency": top["pricing"]["currency"],
+                "confidence_score": confidence,
+                "summary": self._recommendation_summary(top, strategy, customer_row),
+            },
+            "market_context": {
+                "benchmark_amount": benchmark["amount"],
+                "currency": benchmark["currency"],
+                "strategy_name": strategy["name"],
+                "strategy_objective": strategy["objective"],
+                "strategy_adjustment_rate": strategy["adjustment_rate"],
+            },
+            "weights": weights,
+            "candidates": candidates,
+            "rejected_candidates": rejected,
+        }
+        self._store_run(run_id, result, inquiry, strategy)
+        return result
+
+    def get_run(self, run_id: str) -> dict | None:
+        with self._lock:
+            return self.runs.get(run_id)
+
+    def reset_runtime(self) -> None:
+        with self._lock:
+            self.runs.clear()
+            self.audit_graph = Graph()
+
+    def get_audit(self, run_id: str) -> dict | None:
+        run = self.get_run(run_id)
+        if not run:
+            return None
+        run_uri = RUN[run_id]
+        with self._lock:
+            triples = list(self.audit_graph.triples((run_uri, None, None)))
+        return {
+            "run_id": run_id,
+            "generated_at": run["generated_at"],
+            "input": run["normalized_inquiry"],
+            "weights": run["weights"],
+            "market_context": run["market_context"],
+            "candidate_count": len(run["candidates"]),
+            "rejected_count": len(run["rejected_candidates"]),
+            "rdf_assertions": len(triples),
+            "sources": self.repository.loaded_file_names,
+        }
+
+    def _candidate_records(self) -> list[CandidateRecord]:
+        rows = self.repository.query_file(
+            self.root / "queries" / "market-candidates.rq"
+        )["rows"]
+        return [
+            CandidateRecord(
+                quote_uri=row["quote"],
+                quote_no=row["quoteNo"],
+                product_uri=row["product"],
+                product_code=row.get("productCode", ""),
+                product_name=row.get("productName", ""),
+                brand_name=row.get("brandName", ""),
+                transport_mode=row.get("transportMode", ""),
+                offering_uri=row["offering"],
+                offering_code=row.get("offeringCode", ""),
+                supplier_uri=row["supplier"],
+                supplier_name=row["supplierName"],
+                schedule_uri=row.get("schedule", ""),
+                schedule_code=row.get("scheduleCode", ""),
+                route_type=row.get("routeType", ""),
+                service_form=row.get("serviceForm", ""),
+                origin=row.get("originLocation", ""),
+                destination=row.get("destinationLocation", ""),
+                currency=row["currency"],
+                supplier_cost=_as_float(row.get("supplierCostAmount")),
+                valid_from=_as_date(row.get("validFrom")),
+                valid_to=_as_date(row.get("validTo")),
+                departure_date=_as_date(row.get("estimatedDepartureTime")),
+                arrival_date=_as_date(row.get("estimatedArrivalTime")),
+                transit_days=_as_float(row.get("estimatedTransitDays")),
+                booking_success_rate=_as_float(row.get("bookingSuccessRate"), 0.8),
+                space_release_success_rate=_as_float(
+                    row.get("spaceReleaseSuccessRate"), 0.8
+                ),
+                etd_on_time_rate=_as_float(row.get("etdAtdOnTimeRate"), 0.85),
+                eta_on_time_rate=_as_float(row.get("etaAtaOnTimeRate"), 0.85),
+                supplier_active=_as_bool(row.get("supplierActive"), True),
+                capacity_available=_as_bool(row.get("capacityAvailable"), True),
+                supports_dangerous_goods=_as_bool(
+                    row.get("supportsDangerousGoods"), False
+                ),
+                dangerous_goods_only=_as_bool(row.get("dangerousGoodsOnly"), False),
+                agency_level=row.get("agencyLevel", ""),
+                offering_channel=row.get("offeringChannel", ""),
+                capacity_commitment=row.get("capacityCommitment", ""),
+            )
+            for row in rows
+        ]
+
+    def _find_customer(self, customer_id: str) -> dict:
+        rows = self.repository.query_file(
+            self.root / "queries" / "market-customers.rq"
+        )["rows"]
+        for row in rows:
+            if row["customer"] == customer_id or row["customerName"] == customer_id:
+                return row
+        raise ValueError("客户不存在或没有可用画像")
+
+    @staticmethod
+    def _customer_signals(row: dict) -> CustomerSignals:
+        service_requirement = ";".join(
+            value
+            for value in (
+                row.get("notificationRequirement", ""),
+                row.get("documentRequirement", ""),
+                row.get("exceptionResponseRequirement", ""),
+            )
+            if value
+        )
+        return CustomerSignals(
+            price_sensitivity=_as_float(row.get("customerPriceSensitivity"), 0.5),
+            time_sensitivity=_as_float(row.get("customerTimeSensitivity"), 0.5),
+            reliability_sensitivity=_as_float(
+                row.get("customerReliabilitySensitivity"), 0.5
+            ),
+            repeat_purchase_count=_as_int(row.get("repeatPurchaseCount")),
+            overdue_days=_as_int(row.get("overdueDays")),
+            service_requirement=service_requirement,
+        )
+
+    def _component_costs(
+        self, evaluation_date: date, origin: str
+    ) -> tuple[float, list[str]]:
+        candidates = self._candidate_records()
+        total = 0.0
+        names: list[str] = []
+        product_codes = self.config["component_product_codes_by_origin"].get(
+            origin,
+            self.config["component_product_codes_by_origin"].get("__default__", []),
+        )
+        for product_code in product_codes:
+            valid = [
+                item
+                for item in candidates
+                if item.product_code == product_code
+                and (not item.valid_from or item.valid_from <= evaluation_date)
+                and (not item.valid_to or evaluation_date <= item.valid_to)
+            ]
+            if valid:
+                selected = min(valid, key=lambda item: item.supplier_cost)
+                total += selected.supplier_cost
+                names.append(selected.product_name)
+        return total, names
+
+    def _market_context(self, inquiry: InquiryRequest) -> tuple[dict, dict]:
+        rows = self.repository.query_file(
+            self.root / "queries" / "market-context.rq"
+        )["rows"]
+        applicable = [
+            row
+            for row in rows
+            if row.get("origin") == inquiry.origin
+            and row.get("destination") == inquiry.destination
+            and _within(inquiry.evaluation_date, row.get("validFrom"), row.get("validTo"))
+        ]
+        benchmark_row = next((row for row in applicable if row["kind"] == "benchmark"), None)
+        strategy_row = next((row for row in applicable if row["kind"] == "strategy"), None)
+        if not benchmark_row or not strategy_row:
+            raise ValueError("当前路线缺少有效的市场基准价格或启用策略")
+        return (
+            {
+                "uri": benchmark_row["resource"],
+                "amount": _as_float(benchmark_row["amount"]),
+                "currency": benchmark_row["currency"],
+            },
+            {
+                "uri": strategy_row["resource"],
+                "name": strategy_row["strategyName"],
+                "objective": strategy_row["strategyObjective"],
+                "adjustment_rate": _as_float(strategy_row["strategyAdjustmentRate"]),
+            },
+        )
+
+    @staticmethod
+    def _candidate_payload(
+        candidate: CandidateRecord,
+        scores: dict,
+        prices: dict,
+        component_names: list[str],
+    ) -> dict:
+        reliability = round(
+            (candidate.booking_success_rate + candidate.space_release_success_rate) / 2,
+            2,
+        )
+        reasons = [
+            f"供应商订舱与放舱综合成功率{reliability:.0%}",
+            f"预计运输时间{candidate.transit_days:g}天",
+            f"{candidate.route_type or '当前'}路由,{candidate.capacity_commitment or '运力已确认'}",
+        ]
+        if scores["solution_type"] == "经济方案":
+            reasons.insert(0, "当前可执行候选中采购价格最有竞争力")
+        elif scores["solution_type"] == "时效方案":
+            reasons.insert(0, "当前可执行候选中预计运输时间最短")
+        else:
+            reasons.insert(0, "客户偏好、供应商可靠性与价格综合表现最均衡")
+
+        return {
+            "id": candidate.quote_uri,
+            "quote_no": candidate.quote_no,
+            "solution_type": scores["solution_type"],
+            "rank": scores["rank"],
+            "brand_name": candidate.brand_name,
+            "product_name": candidate.product_name,
+            "supplier_name": candidate.supplier_name,
+            "offering_channel": candidate.offering_channel,
+            "schedule_code": candidate.schedule_code,
+            "departure_date": candidate.departure_date.isoformat() if candidate.departure_date else "",
+            "arrival_date": candidate.arrival_date.isoformat() if candidate.arrival_date else "",
+            "transit_days": candidate.transit_days,
+            "route_type": candidate.route_type,
+            "capacity_commitment": candidate.capacity_commitment,
+            "components": [*component_names, candidate.product_name],
+            "reliability_rate": reliability,
+            "price_score": scores["price_score"],
+            "transit_score": scores["transit_score"],
+            "supplier_execution_score": scores["supplier_execution_score"],
+            "customer_fit_score": scores["customer_fit_score"],
+            "product_strategy_score": scores["product_strategy_score"],
+            "total_score": scores["total_score"],
+            "confidence_score": None,
+            "reasons": reasons,
+            "pricing": prices,
+        }
+
+    @staticmethod
+    def _rejected_payload(candidate: CandidateRecord, reasons: list[str]) -> dict:
+        return {
+            "id": candidate.quote_uri,
+            "quote_no": candidate.quote_no,
+            "product_name": candidate.product_name,
+            "supplier_name": candidate.supplier_name,
+            "schedule_code": candidate.schedule_code,
+            "reasons": reasons,
+        }
+
+    @staticmethod
+    def _recommendation_summary(
+        candidate: dict, strategy: dict, customer_row: dict
+    ) -> str:
+        return (
+            f"为{customer_row['customerName']}推荐{candidate['solution_type']}:"
+            f"{candidate['supplier_name']}的{candidate['product_name']}。"
+            f"综合得分{candidate['total_score']:.1f},采用“{strategy['name']}”,"
+            f"在时效、履约可靠性和客户价格接受度之间取得当前最优平衡。"
+        )
+
+    def _store_run(
+        self, run_id: str, result: dict, inquiry: InquiryRequest, strategy: dict
+    ) -> None:
+        run_uri = RUN[run_id]
+        generated_at = Literal(result["generated_at"], datatype=XSD.dateTime)
+        with self._lock:
+            self.runs[run_id] = result
+            self.audit_graph.add((run_uri, RDF.type, GESLI.RecommendationRun))
+            self.audit_graph.add((run_uri, GESLI.generatedAt, generated_at))
+            self.audit_graph.add((run_uri, GESLI.usesMarketStrategy, URIRef(strategy["uri"])))
+            self.audit_graph.add((run_uri, GESLI.recommendationReason, Literal(result["recommendation"]["summary"])))
+            customer_uri = URIRef(inquiry.customer)
+            self.audit_graph.add((run_uri, GESLI.quoteForCustomer, customer_uri))
+            for candidate in result["candidates"]:
+                evaluation_uri = RUN[f"{run_id}/candidate/{candidate['rank']}"]
+                self.audit_graph.add((evaluation_uri, RDF.type, GESLI.CandidateEvaluation))
+                self.audit_graph.add((run_uri, GESLI.hasCandidateEvaluation, evaluation_uri))
+                self.audit_graph.add((evaluation_uri, GESLI.evaluatedQuote, URIRef(candidate["id"])))
+                self.audit_graph.add((evaluation_uri, GESLI.recommendationRank, Literal(candidate["rank"])))
+                self.audit_graph.add((evaluation_uri, GESLI.totalScore, Literal(candidate["total_score"])))
+            top_evaluation = RUN[f"{run_id}/candidate/1"]
+            self.audit_graph.add((run_uri, GESLI.recommendedEvaluation, top_evaluation))
+
+
+def _as_float(value: str | None, default: float = 0.0) -> float:
+    try:
+        return float(value) if value not in (None, "") else default
+    except (TypeError, ValueError):
+        return default
+
+
+def _as_int(value: str | None, default: int = 0) -> int:
+    try:
+        return int(value) if value not in (None, "") else default
+    except (TypeError, ValueError):
+        return default
+
+
+def _as_bool(value: str | None, default: bool = False) -> bool:
+    if value in (None, ""):
+        return default
+    return str(value).lower() in {"true", "1", "yes"}
+
+
+def _as_date(value: str | None) -> date | None:
+    if not value:
+        return None
+    try:
+        return date.fromisoformat(value[:10])
+    except ValueError:
+        return None
+
+
+def _within(current: date, start: str | None, end: str | None) -> bool:
+    start_date = _as_date(start)
+    end_date = _as_date(end)
+    return (not start_date or start_date <= current) and (not end_date or current <= end_date)

+ 333 - 0
app/mock_data.py

@@ -0,0 +1,333 @@
+from __future__ import annotations
+
+from datetime import date, datetime, time, timedelta, timezone
+from pathlib import Path
+from uuid import uuid4
+
+from pydantic import BaseModel, Field
+from rdflib import Literal, Namespace, RDF, RDFS, XSD
+
+from app.market import MarketService
+from app.repository import RDFRepository
+
+
+GESLI = Namespace("https://gesli.example/ontology#")
+MOCK = Namespace("https://gesli.example/runtime-mock/")
+
+
+class MockBatchRequest(BaseModel):
+    origin: str
+    destination: str
+    supplier_count: int = Field(default=2, ge=1, le=5)
+    customer_count: int = Field(default=1, ge=0, le=3)
+    evaluation_date: date = date(2026, 7, 20)
+
+
+class LocalMockDataService:
+    def __init__(
+        self,
+        root: Path,
+        repository: RDFRepository,
+        market_service: MarketService,
+    ):
+        self.root = root
+        self.repository = repository
+        self.market_service = market_service
+
+    def catalog(self) -> dict:
+        candidate_rows = self.repository.query_file(
+            self.root / "queries" / "market-candidates.rq"
+        )["rows"]
+        context_rows = self.repository.query_file(
+            self.root / "queries" / "market-context.rq"
+        )["rows"]
+        customers = self.market_service.customers()
+        evaluation_date = date(2026, 7, 20)
+
+        routes: dict[tuple[str, str], dict] = {}
+        for row in candidate_rows:
+            origin = row.get("originLocation", "")
+            destination = row.get("destinationLocation", "")
+            if not origin or not destination:
+                continue
+            key = (origin, destination)
+            route = routes.setdefault(key, {
+                "origin": origin,
+                "destination": destination,
+                "products": set(),
+                "suppliers": set(),
+                "quotes": set(),
+                "current_quotes": 0,
+                "transport_modes": set(),
+            })
+            route["products"].add(row.get("product", ""))
+            route["suppliers"].add(row.get("supplier", ""))
+            route["quotes"].add(row.get("quote", ""))
+            if row.get("transportMode"):
+                route["transport_modes"].add(row["transportMode"])
+            if _within(evaluation_date, row.get("validFrom"), row.get("validTo")):
+                route["current_quotes"] += 1
+
+        route_rows = [
+            {
+                "origin": route["origin"],
+                "destination": route["destination"],
+                "transport_modes": " / ".join(sorted(route["transport_modes"])),
+                "product_count": len(route["products"]),
+                "supplier_count": len(route["suppliers"]),
+                "quote_count": len(route["quotes"]),
+                "current_quote_count": route["current_quotes"],
+            }
+            for route in routes.values()
+        ]
+        route_rows.sort(key=lambda item: (item["origin"], item["destination"]))
+
+        quote_rows = []
+        for row in candidate_rows:
+            valid_from = row.get("validFrom", "")
+            valid_to = row.get("validTo", "")
+            if _within(evaluation_date, valid_from, valid_to):
+                status = "当前有效"
+            elif valid_to and valid_to < evaluation_date.isoformat():
+                status = "已过期"
+            else:
+                status = "尚未生效"
+            quote_rows.append({
+                "quote_no": row.get("quoteNo", ""),
+                "route": f"{row.get('originLocation', '')} → {row.get('destinationLocation', '')}",
+                "transport_mode": row.get("transportMode", ""),
+                "product_name": row.get("productName", ""),
+                "supplier_name": row.get("supplierName", ""),
+                "supplier_cost_amount": _as_float(row.get("supplierCostAmount")),
+                "currency": row.get("currency", ""),
+                "valid_to": valid_to,
+                "status": status,
+                "dangerous_goods": row.get("supportsDangerousGoods", "false") == "true",
+                "runtime_mock": row.get("quote", "").startswith(str(MOCK)),
+            })
+        quote_rows.sort(
+            key=lambda item: (
+                item["route"], item["status"], item["supplier_cost_amount"]
+            )
+        )
+
+        strategies = [
+            {
+                "name": row.get("strategyName", ""),
+                "objective": row.get("strategyObjective", ""),
+                "route": f"{row.get('origin', '')} → {row.get('destination', '')}",
+                "adjustment_rate": _as_float(row.get("strategyAdjustmentRate")),
+                "valid_to": row.get("validTo", ""),
+            }
+            for row in context_rows
+            if row.get("kind") == "strategy"
+        ]
+
+        return {
+            "summary": {
+                "triple_count": self.repository.triple_count,
+                "in_memory_triple_count": self.repository.in_memory_triple_count,
+                "customer_count": len(customers),
+                "supplier_count": len({row.get("supplier") for row in candidate_rows}),
+                "product_count": len({row.get("product") for row in candidate_rows}),
+                "quote_count": len({row.get("quote") for row in candidate_rows}),
+                "route_count": len(route_rows),
+                "strategy_count": len(strategies),
+            },
+            "routes": route_rows,
+            "customers": customers,
+            "quotes": quote_rows,
+            "strategies": strategies,
+            "loaded_files": self.repository.loaded_file_names,
+        }
+
+    def generate(self, request: MockBatchRequest) -> dict:
+        route_context = next(
+            (
+                route
+                for route in self.market_service.form_options()["routes"]
+                if route["origin"] == request.origin
+                and route["destination"] == request.destination
+            ),
+            None,
+        )
+        if not route_context:
+            raise ValueError("只能为已经配置市场基准和策略的路线生成Mock数据")
+
+        token = uuid4().hex[:8]
+        benchmark = float(route_context["target_amount"])
+        transport_mode = self._route_transport_mode(
+            request.origin, request.destination
+        )
+        triples = []
+        generated_suppliers = []
+        generated_customers = []
+
+        for index in range(request.supplier_count):
+            suffix = f"{token}-{index + 1}"
+            brand = MOCK[f"brand-{suffix}"]
+            supplier = MOCK[f"supplier-{suffix}"]
+            product = MOCK[f"product-{suffix}"]
+            offering = MOCK[f"offering-{suffix}"]
+            schedule = MOCK[f"schedule-{suffix}"]
+            quote = MOCK[f"quote-{suffix}"]
+            performance = MOCK[f"performance-{suffix}"]
+            supplier_name = f"内存模拟供应商 {token[-3:].upper()}{index + 1}"
+            product_name = (
+                f"{request.origin}-{request.destination}动态Mock方案{index + 1}"
+            )
+            departure = request.evaluation_date + timedelta(days=8 + index)
+            transit_days = self._transit_days(transport_mode, index)
+            arrival = departure + timedelta(days=transit_days)
+            cost = round(benchmark * (0.76 + index * 0.025), -2)
+            success_rate = min(0.98, 0.87 + index * 0.035)
+            product_class = (
+                GESLI.AirFreightProduct
+                if transport_mode == "空运"
+                else GESLI.ContainerOceanProduct
+            )
+            route_type = "直达" if transport_mode == "空运" and index == 0 else (
+                "直航" if index % 2 == 0 else "中转"
+            )
+
+            triples.extend([
+                (brand, RDF.type, GESLI.ServiceBrand),
+                (brand, GESLI.brandCode, Literal(f"MOCK-{token}-{index + 1}")),
+                (brand, GESLI.brandName, Literal(f"Mock品牌 {index + 1}")),
+                (supplier, RDF.type, GESLI.FreightForwarderSupplier),
+                (supplier, RDFS.label, Literal(supplier_name, lang="zh")),
+                (supplier, GESLI.platformSupplierType, Literal("内存Mock供应商")),
+                (supplier, GESLI.isActivePartner, Literal(True)),
+                (product, RDF.type, product_class),
+                (product, GESLI.productCode, Literal(f"MOCK-PRODUCT-{suffix}")),
+                (product, GESLI.productName, Literal(product_name)),
+                (product, GESLI.hasServiceBrand, brand),
+                (product, GESLI.hasProductPerformanceProfile, performance),
+                (product, GESLI.transportMode, Literal(transport_mode)),
+                (product, GESLI.serviceForm, Literal("Mock标准服务")),
+                (product, GESLI.routeType, Literal(route_type)),
+                (product, GESLI.originLocation, Literal(request.origin)),
+                (product, GESLI.destinationLocation, Literal(request.destination)),
+                (product, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
+                (performance, RDF.type, GESLI.ProductPerformanceProfile),
+                (performance, GESLI.etdAtdOnTimeRate, Literal(success_rate, datatype=XSD.decimal)),
+                (performance, GESLI.etaAtaOnTimeRate, Literal(success_rate - 0.02, datatype=XSD.decimal)),
+                (offering, RDF.type, GESLI.SupplierProductOffering),
+                (offering, GESLI.offeringCode, Literal(f"MOCK-OFFER-{suffix}")),
+                (offering, GESLI.offeredBySupplier, supplier),
+                (offering, GESLI.offeringProduct, product),
+                (offering, GESLI.offeringChannel, Literal("本地动态Mock")),
+                (offering, GESLI.agencyLevel, Literal("一级模拟代理")),
+                (offering, GESLI.capacityCommitment, Literal(f"模拟可用运力{10 + index * 5}单位")),
+                (offering, GESLI.capacityAvailable, Literal(True)),
+                (offering, GESLI.supportsDangerousGoods, Literal(index == 0)),
+                (offering, GESLI.bookingSuccessRate, Literal(success_rate, datatype=XSD.decimal)),
+                (offering, GESLI.spaceReleaseSuccessRate, Literal(success_rate - 0.01, datatype=XSD.decimal)),
+                (schedule, RDF.type, GESLI.ScheduledServiceInstance),
+                (schedule, GESLI.scheduleCode, Literal(f"MOCK-SCHEDULE-{suffix}")),
+                (schedule, GESLI.scheduleOfProduct, product),
+                (schedule, GESLI.documentCutoffTime, _date_time_literal(departure - timedelta(days=2))),
+                (schedule, GESLI.estimatedDepartureTime, _date_time_literal(departure)),
+                (schedule, GESLI.estimatedArrivalTime, _date_time_literal(arrival)),
+                (schedule, GESLI.estimatedTransitDays, Literal(transit_days, datatype=XSD.decimal)),
+                (quote, RDF.type, GESLI.SupplierQuote),
+                (quote, GESLI.quoteNo, Literal(f"MOCK-QUOTE-{suffix}")),
+                (quote, GESLI.quotedBySupplier, supplier),
+                (quote, GESLI.quotedForOffering, offering),
+                (quote, GESLI.quoteAppliesToSchedule, schedule),
+                (quote, GESLI.supplierCostAmount, Literal(cost, datatype=XSD.decimal)),
+                (quote, GESLI.currency, Literal("CNY")),
+                (quote, GESLI.validFrom, Literal(request.evaluation_date, datatype=XSD.date)),
+                (quote, GESLI.validTo, Literal(request.evaluation_date + timedelta(days=30), datatype=XSD.date)),
+            ])
+            generated_suppliers.append(supplier_name)
+
+        for index in range(request.customer_count):
+            suffix = f"{token}-{index + 1}"
+            customer = MOCK[f"customer-{suffix}"]
+            trade = MOCK[f"customer-trade-{suffix}"]
+            credit = MOCK[f"customer-credit-{suffix}"]
+            customer_name = f"内存模拟客户 {token[-3:].upper()}{index + 1}"
+            triples.extend([
+                (customer, RDF.type, GESLI.Customer),
+                (customer, RDFS.label, Literal(customer_name, lang="zh")),
+                (customer, GESLI.hasTradeProfile, trade),
+                (customer, GESLI.hasCreditProfile, credit),
+                (trade, RDF.type, GESLI.CustomerTradeProfile),
+                (trade, GESLI.industry, Literal("动态Mock行业")),
+                (trade, GESLI.routeName, Literal(f"{request.origin}-{request.destination}")),
+                (trade, GESLI.repeatPurchaseCount, Literal(4 + index * 6)),
+                (trade, GESLI.quoteWinRate, Literal(0.5 + index * 0.08, datatype=XSD.decimal)),
+                (trade, GESLI.customerPriceSensitivity, Literal(0.45 + index * 0.15, datatype=XSD.decimal)),
+                (trade, GESLI.customerTimeSensitivity, Literal(0.75 - index * 0.1, datatype=XSD.decimal)),
+                (trade, GESLI.customerReliabilitySensitivity, Literal(0.7 + index * 0.1, datatype=XSD.decimal)),
+                (credit, RDF.type, GESLI.CustomerCreditProfile),
+                (credit, GESLI.overdueDays, Literal(index)),
+            ])
+            generated_customers.append(customer_name)
+
+        added_triples = self.repository.add_triples(triples)
+        self.market_service.reset_runtime()
+        return {
+            "batch_id": token,
+            "added_triples": added_triples,
+            "suppliers": generated_suppliers,
+            "customers": generated_customers,
+            "catalog": self.catalog(),
+        }
+
+    def reset(self) -> dict:
+        self.repository.reload()
+        self.market_service.reset_runtime()
+        return self.catalog()
+
+    def _route_transport_mode(self, origin: str, destination: str) -> str:
+        rows = self.repository.query_file(
+            self.root / "queries" / "market-candidates.rq"
+        )["rows"]
+        return next(
+            (
+                row.get("transportMode", "海运")
+                for row in rows
+                if row.get("originLocation") == origin
+                and row.get("destinationLocation") == destination
+            ),
+            "海运",
+        )
+
+    @staticmethod
+    def _transit_days(transport_mode: str, index: int) -> int:
+        return (3 + index) if transport_mode == "空运" else (14 + index * 2)
+
+
+def _as_float(value: str | None, default: float = 0.0) -> float:
+    try:
+        return float(value) if value not in (None, "") else default
+    except (TypeError, ValueError):
+        return default
+
+
+def _as_date(value: str | None) -> date | None:
+    if not value:
+        return None
+    try:
+        return date.fromisoformat(value[:10])
+    except ValueError:
+        return None
+
+
+def _within(current: date, start: str | None, end: str | None) -> bool:
+    start_date = _as_date(start)
+    end_date = _as_date(end)
+    return (not start_date or start_date <= current) and (
+        not end_date or current <= end_date
+    )
+
+
+def _date_time_literal(value: date) -> Literal:
+    dt = datetime.combine(
+        value,
+        time(hour=12),
+        tzinfo=timezone(timedelta(hours=8)),
+    )
+    return Literal(dt.isoformat(), datatype=XSD.dateTime)

+ 78 - 0
app/repository.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from pathlib import Path
+from threading import RLock
+
+from rdflib import Graph
+from rdflib.query import ResultRow
+from rdflib.term import Node
+
+
+class RDFRepository:
+    """Loads the ontology and data files into one local, queryable RDF graph."""
+
+    def __init__(self, root: Path):
+        self.root = root
+        self.graph = Graph()
+        self._lock = RLock()
+        self.loaded_files: list[Path] = []
+        self.reload()
+
+    def reload(self) -> None:
+        paths = [
+            self.root / "ontology" / "global-shipment.ttl",
+            *sorted((self.root / "ontology").glob("*-extension.ttl")),
+            *sorted((self.root / "data").glob("*.ttl")),
+        ]
+
+        graph = Graph()
+        loaded_files: list[Path] = []
+        for path in paths:
+            if path.exists() and path not in loaded_files:
+                graph.parse(path, format="turtle")
+                loaded_files.append(path)
+
+        with self._lock:
+            self.graph = graph
+            self.loaded_files = loaded_files
+            self.base_triple_count = len(graph)
+
+    def add_triples(self, triples: list[tuple[Node, Node, Node]]) -> int:
+        with self._lock:
+            before = len(self.graph)
+            for triple in triples:
+                self.graph.add(triple)
+            return len(self.graph) - before
+
+    def query_file(self, path: Path) -> dict[str, list[dict[str, str]]]:
+        if not path.exists():
+            raise FileNotFoundError(path)
+        return self.query(path.read_text(encoding="utf-8"))
+
+    def query(self, sparql: str) -> dict[str, list[dict[str, str]]]:
+        with self._lock:
+            result = self.graph.query(sparql)
+
+        variables = [str(variable) for variable in result.vars or []]
+        rows = [self._row_to_dict(row, variables) for row in result]
+        return {"rows": rows}
+
+    @staticmethod
+    def _row_to_dict(row: ResultRow, variables: list[str]) -> dict[str, str]:
+        values: dict[str, str] = {}
+        for variable in variables:
+            value = row.get(variable)
+            values[variable] = "" if value is None else str(value)
+        return values
+
+    @property
+    def triple_count(self) -> int:
+        return len(self.graph)
+
+    @property
+    def in_memory_triple_count(self) -> int:
+        return self.triple_count - self.base_triple_count
+
+    @property
+    def loaded_file_names(self) -> list[str]:
+        return [str(path.relative_to(self.root)) for path in self.loaded_files]

+ 532 - 16
app/static/app.js

@@ -31,10 +31,503 @@ const labels = {
   estimatedSailingTime: "预计开船",
 };
 
-document.getElementById("refreshBtn").addEventListener("click", loadAll);
-loadAll();
+let currentRunId = null;
+let operationsLoaded = false;
+let marketOptions = null;
+let mockCatalog = null;
+let activeCatalog = "routes";
 
-async function loadAll() {
+document.querySelectorAll(".view-tab").forEach(button => {
+  button.addEventListener("click", () => switchView(button.dataset.view));
+});
+document.getElementById("inquiryForm").addEventListener("submit", submitInquiry);
+document.getElementById("recommendBtn").addEventListener("click", submitInquiry);
+document.getElementById("refreshBtn").addEventListener("click", loadOperations);
+document.getElementById("auditBtn").addEventListener("click", openAudit);
+document.getElementById("generateMockBtn").addEventListener("click", generateMockBatch);
+document.getElementById("resetMockBtn").addEventListener("click", resetMockData);
+document.getElementById("origin").addEventListener("change", () => syncRouteControls(true));
+document.getElementById("destination").addEventListener("change", () => applyRouteDefaults(true));
+document.querySelectorAll(".catalog-tab").forEach(button => {
+  button.addEventListener("click", () => selectCatalog(button.dataset.catalog));
+});
+document.getElementById("closeAuditBtn").addEventListener("click", () => {
+  document.getElementById("auditDialog").close();
+});
+
+initialize();
+
+async function initialize() {
+  try {
+    const [health, options] = await Promise.all([
+      fetchJson("/health"),
+      fetchJson("/api/market/form-options"),
+    ]);
+    document.getElementById("systemStatus").textContent =
+      `本地知识图谱 · ${numberFormat(health.triple_count)} triples`;
+    marketOptions = options;
+    populateInquiryForm(options);
+    await requestRecommendation(options.defaults);
+  } catch (error) {
+    renderMarketError(error.message);
+  }
+}
+
+function switchView(view) {
+  document.querySelectorAll(".view-tab").forEach(button => {
+    button.classList.toggle("is-active", button.dataset.view === view);
+  });
+  document.querySelectorAll(".app-view").forEach(section => {
+    section.classList.toggle("is-active", section.id === `${view}View`);
+  });
+  if (view === "operations" && !operationsLoaded) loadOperations();
+  if (view === "mock" && !mockCatalog) loadMockCatalog();
+}
+
+function populateInquiryForm(options) {
+  populateSelect("customer", options.customers, "id", "name", options.defaults.customer);
+  populateSelect("origin", options.origins, null, null, options.defaults.origin);
+  syncRouteControls(false, options.defaults.destination);
+  const defaultRoute = options.routes.find(route =>
+    route.origin === options.defaults.origin && route.destination === options.defaults.destination
+  );
+  if (defaultRoute) {
+    populateSelect(
+      "routeType",
+      defaultRoute.route_types,
+      null,
+      null,
+      options.defaults.preferred_route_type,
+    );
+  }
+  document.getElementById("cargoName").value = options.defaults.cargo_name;
+  document.getElementById("cargoVolume").value = options.defaults.cargo_volume;
+  document.getElementById("evaluationDate").value = options.defaults.evaluation_date;
+  document.getElementById("departureDate").value = options.defaults.requested_departure_date;
+  document.getElementById("maxTransitDays").value = options.defaults.max_transit_days;
+  document.getElementById("targetAmount").value = options.defaults.target_amount;
+  document.getElementById("dangerousGoods").checked = options.defaults.dangerous_goods;
+  populateMockRouteSelect();
+}
+
+function syncRouteControls(updateDefaults, selectedDestination) {
+  if (!marketOptions) return;
+  const origin = document.getElementById("origin").value;
+  const destinations = marketOptions.routes
+    .filter(route => route.origin === origin)
+    .map(route => route.destination);
+  const preferredDestination = selectedDestination && destinations.includes(selectedDestination)
+    ? selectedDestination
+    : destinations[0];
+  populateSelect("destination", destinations, null, null, preferredDestination);
+  applyRouteDefaults(updateDefaults);
+}
+
+function applyRouteDefaults(updateValues) {
+  if (!marketOptions) return;
+  const origin = document.getElementById("origin").value;
+  const destination = document.getElementById("destination").value;
+  const route = marketOptions.routes.find(item => item.origin === origin && item.destination === destination);
+  if (!route) return;
+  const currentRouteType = document.getElementById("routeType").value;
+  const preferredRouteType = route.route_types.includes(currentRouteType)
+    ? currentRouteType
+    : route.route_types[0];
+  populateSelect("routeType", route.route_types, null, null, preferredRouteType);
+  if (updateValues) {
+    document.getElementById("targetAmount").value = route.target_amount;
+    document.getElementById("departureDate").value = route.requested_departure_date;
+    document.getElementById("maxTransitDays").value = Math.ceil(route.max_transit_days);
+  }
+}
+
+function populateMockRouteSelect() {
+  if (!marketOptions) return;
+  const routes = marketOptions.routes.map((route, index) => ({
+    id: String(index),
+    name: `${route.origin} → ${route.destination}`,
+  }));
+  populateSelect("mockRoute", routes, "id", "name", "0");
+}
+
+function populateSelect(id, values, valueKey, labelKey, selectedValue) {
+  const select = document.getElementById(id);
+  select.innerHTML = values.map(item => {
+    const value = valueKey ? item[valueKey] : item;
+    const label = labelKey ? item[labelKey] : item;
+    return `<option value="${escapeHtml(value)}"${value === selectedValue ? " selected" : ""}>${escapeHtml(label)}</option>`;
+  }).join("");
+}
+
+async function submitInquiry(event) {
+  event.preventDefault();
+  const form = new FormData(document.getElementById("inquiryForm"));
+  const inquiry = {
+    customer: form.get("customer"),
+    origin: form.get("origin"),
+    destination: form.get("destination"),
+    cargo_name: form.get("cargo_name"),
+    cargo_volume: Number(form.get("cargo_volume")),
+    evaluation_date: form.get("evaluation_date"),
+    requested_departure_date: form.get("requested_departure_date"),
+    max_transit_days: Number(form.get("max_transit_days")),
+    target_amount: Number(form.get("target_amount")),
+    preferred_route_type: form.get("preferred_route_type"),
+    dangerous_goods: document.getElementById("dangerousGoods").checked,
+    salesperson_adjustment: 0,
+  };
+  await requestRecommendation(inquiry);
+}
+
+async function requestRecommendation(inquiry) {
+  setRecommendationLoading(true);
+  try {
+    const result = await fetchJson("/api/market/recommend", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(inquiry),
+    });
+    currentRunId = result.run_id;
+    renderMarketResult(result);
+  } catch (error) {
+    currentRunId = null;
+    renderMarketError(error.message);
+  } finally {
+    setRecommendationLoading(false);
+  }
+}
+
+function setRecommendationLoading(loading) {
+  const button = document.getElementById("recommendBtn");
+  button.disabled = loading;
+  button.querySelector("span:first-child").textContent = loading ? "正在计算" : "生成市场方案";
+  document.getElementById("inquiryState").textContent = loading ? "决策计算中" : "已标准化";
+  if (loading) {
+    document.getElementById("recommendationBand").className = "recommendation-band is-loading";
+    document.getElementById("recommendationBand").innerHTML = `
+      <div class="loading-block"><span class="loading-line"></span><span class="loading-line short"></span></div>`;
+  }
+}
+
+function renderMarketResult(result) {
+  const inquiry = result.normalized_inquiry;
+  const recommendation = result.recommendation;
+  document.getElementById("decisionTitle").textContent = `${inquiry.customer_name} · ${inquiry.route}`;
+  document.getElementById("inquiryState").textContent = `任务 ${result.run_id}`;
+  document.getElementById("auditBtn").disabled = false;
+  renderPipeline(result.pipeline);
+  renderRecommendation(result);
+  renderCandidates(result.candidates);
+  renderRejected(result.rejected_candidates);
+}
+
+function renderPipeline(pipeline) {
+  const steps = [
+    ["DISCOVER", pipeline.discovered, "发现候选", ""],
+    ["REJECT", pipeline.rejected, "硬条件淘汰", "is-rejected"],
+    ["FEASIBLE", pipeline.feasible, "通过可执行校验", "is-accent"],
+    ["SOLUTIONS", pipeline.generated_solutions, "生成市场方案", "is-accent"],
+  ];
+  document.getElementById("pipeline").innerHTML = steps.map(([code, value, label, className]) => `
+    <div class="pipeline-step ${className}">
+      <span>${code}</span><strong>${escapeHtml(value)}</strong><em>${label}</em>
+    </div>`).join("");
+}
+
+function renderRecommendation(result) {
+  const recommendation = result.recommendation;
+  const top = result.candidates.find(item => item.id === recommendation.candidate_id);
+  const pricing = top.pricing;
+  const context = result.market_context;
+  const confidence = Math.round(recommendation.confidence_score * 100);
+  const band = document.getElementById("recommendationBand");
+  band.className = "recommendation-band";
+  band.innerHTML = `
+    <div class="recommendation-copy">
+      <span class="recommendation-label">AI MARKET RECOMMENDATION</span>
+      <h2>${escapeHtml(recommendation.solution_type)} · ${escapeHtml(recommendation.supplier_name)}</h2>
+      <p>${escapeHtml(recommendation.summary)}</p>
+      <div class="recommendation-tags">
+        <span>${escapeHtml(top.route_type)}</span>
+        <span>${escapeHtml(top.transit_days)} 天</span>
+        <span>可靠性 ${percent(top.reliability_rate)}</span>
+        <span>${escapeHtml(context.strategy_name)}</span>
+      </div>
+    </div>
+    <div class="quote-block">
+      <span>客户最终建议报价</span>
+      <div class="quote-amount">${money(pricing.final_quote_amount)}<small>${escapeHtml(pricing.currency)}</small></div>
+      <div class="quote-range">授权区间 ${money(pricing.allowed_quote_lower_amount)} – ${money(pricing.allowed_quote_upper_amount)}</div>
+      <div class="confidence-row">
+        <span>置信度 ${confidence}%</span>
+        <div class="confidence-track"><span style="width:${confidence}%"></span></div>
+      </div>
+    </div>`;
+}
+
+function renderCandidates(candidates) {
+  document.getElementById("candidateMeta").textContent = `${candidates.length} 套可执行方案 · 按综合得分排序`;
+  document.getElementById("candidateGrid").innerHTML = candidates.map((candidate, index) => {
+    const typeClass = candidate.solution_type === "经济方案"
+      ? "type-economy"
+      : candidate.solution_type === "时效方案" ? "type-speed" : "type-balanced";
+    const riskClass = candidate.pricing.cost_risk_status.includes("底线")
+      ? "is-danger"
+      : candidate.pricing.cost_risk_status.includes("安全") ? "is-safe" : "";
+    return `
+      <article class="candidate-card ${typeClass}" style="animation-delay:${index * 70}ms">
+        <div class="candidate-head">
+          <div class="candidate-type-row">
+            <span class="candidate-type">${escapeHtml(candidate.solution_type)}</span>
+            <span class="candidate-rank">RANK ${candidate.rank}</span>
+          </div>
+          <h3>${escapeHtml(candidate.product_name)}</h3>
+          <p class="candidate-supplier">${escapeHtml(candidate.supplier_name)} · ${escapeHtml(candidate.offering_channel)}</p>
+        </div>
+        <div class="candidate-facts">
+          <div><span>预计时效</span><strong>${escapeHtml(candidate.transit_days)} 天</strong></div>
+          <div><span>预计开航</span><strong>${shortDate(candidate.departure_date)}</strong></div>
+          <div><span>综合得分</span><strong>${candidate.total_score}</strong></div>
+        </div>
+        <div class="score-list">
+          ${scoreRow("客户匹配", candidate.customer_fit_score)}
+          ${scoreRow("价格竞争", candidate.price_score)}
+          ${scoreRow("运输时效", candidate.transit_score)}
+          ${scoreRow("供应履约", candidate.supplier_execution_score)}
+          ${scoreRow("产品策略", candidate.product_strategy_score)}
+        </div>
+        <div class="candidate-reason">${candidate.reasons.slice(0, 2).map(escapeHtml).join(";")}</div>
+        <div class="candidate-price">
+          <div><span>建议客户报价</span><strong>${money(candidate.pricing.final_quote_amount)}</strong></div>
+          <em class="risk-label ${riskClass}">${escapeHtml(candidate.pricing.cost_risk_status)}</em>
+        </div>
+      </article>`;
+  }).join("");
+}
+
+function scoreRow(label, value) {
+  const width = Math.max(0, Math.min(100, Number(value)));
+  return `<div class="score-row"><span>${label}</span><div class="score-track"><span style="width:${width}%"></span></div><strong>${value}</strong></div>`;
+}
+
+function renderRejected(rejected) {
+  document.getElementById("rejectedMeta").textContent = `${rejected.length} 个淘汰候选`;
+  document.getElementById("rejectedList").innerHTML = rejected.length
+    ? rejected.map(item => `
+      <div class="rejected-row">
+        <strong>${escapeHtml(item.product_name)}</strong>
+        <span>${escapeHtml(item.supplier_name)} · ${escapeHtml(item.quote_no)}</span>
+        <div class="rejected-reasons">${item.reasons.map(reason => `<em>${escapeHtml(reason)}</em>`).join("")}</div>
+      </div>`).join("")
+    : `<div class="empty">本次没有候选被硬条件淘汰</div>`;
+}
+
+function renderMarketError(message) {
+  document.getElementById("decisionTitle").textContent = "当前询价无法生成可执行方案";
+  document.getElementById("auditBtn").disabled = true;
+  document.getElementById("pipeline").innerHTML = "";
+  const band = document.getElementById("recommendationBand");
+  band.className = "recommendation-band";
+  band.innerHTML = `<div class="recommendation-copy"><span class="recommendation-label">DECISION STOPPED</span><h2>${escapeHtml(message)}</h2></div>`;
+  document.getElementById("candidateGrid").innerHTML = "";
+  document.getElementById("candidateMeta").textContent = "0 套可执行方案";
+}
+
+async function openAudit() {
+  if (!currentRunId) return;
+  const dialog = document.getElementById("auditDialog");
+  const target = document.getElementById("auditContent");
+  target.innerHTML = `<div class="empty">正在读取审计记录</div>`;
+  dialog.showModal();
+  try {
+    const audit = await fetchJson(`/api/market/recommendations/${encodeURIComponent(currentRunId)}/audit`);
+    target.innerHTML = `
+      <div class="audit-grid">
+        ${auditItem("推荐任务", audit.run_id)}
+        ${auditItem("生成时间", audit.generated_at)}
+        ${auditItem("客户与路线", `${audit.input.customer_name} · ${audit.input.route}`)}
+        ${auditItem("候选结果", `${audit.candidate_count} 个通过 / ${audit.rejected_count} 个淘汰`)}
+        ${auditItem("市场策略", audit.market_context.strategy_name)}
+        ${auditItem("RDF决策断言", `${audit.rdf_assertions} 条`)}
+      </div>
+      <div class="audit-sources">
+        <h3>本次决策数据来源</h3>
+        ${audit.sources.map(source => `<code>${escapeHtml(source)}</code>`).join("")}
+      </div>`;
+  } catch (error) {
+    target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
+  }
+}
+
+function auditItem(label, value) {
+  return `<div class="audit-item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
+}
+
+async function loadMockCatalog() {
+  const target = document.getElementById("mockCatalogTable");
+  target.innerHTML = `<div class="empty">正在读取本地 RDF 数据</div>`;
+  try {
+    mockCatalog = await fetchJson("/api/mock/catalog");
+    renderMockCatalog();
+  } catch (error) {
+    target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
+  }
+}
+
+function renderMockCatalog() {
+  if (!mockCatalog) return;
+  const summary = mockCatalog.summary;
+  const stats = [
+    ["RDF 三元组", summary.triple_count, summary.in_memory_triple_count ? `+${summary.in_memory_triple_count} 内存` : "基础图", summary.in_memory_triple_count > 0],
+    ["客户", summary.customer_count, "Customer", false],
+    ["供应商", summary.supplier_count, "Supplier", false],
+    ["产品", summary.product_count, "ServiceProduct", false],
+    ["报价", summary.quote_count, "SupplierQuote", false],
+    ["路线", summary.route_count, "Route", false],
+    ["启用策略", summary.strategy_count, "Strategy", false],
+  ];
+  document.getElementById("mockSummary").innerHTML = stats.map(([label, value, note, hasMemory]) => `
+    <div class="data-stat ${hasMemory ? "has-memory" : ""}">
+      <span>${escapeHtml(label)}</span><strong>${numberFormat(value)}</strong><em>${escapeHtml(note)}</em>
+    </div>`).join("");
+  document.getElementById("mockSources").innerHTML = mockCatalog.loaded_files
+    .map(source => `<code>${escapeHtml(source)}</code>`).join("");
+  document.getElementById("systemStatus").textContent = summary.in_memory_triple_count
+    ? `本地知识图谱 · ${numberFormat(summary.triple_count)} triples · +${summary.in_memory_triple_count} 内存`
+    : `本地知识图谱 · ${numberFormat(summary.triple_count)} triples`;
+  renderActiveCatalogTable();
+}
+
+function selectCatalog(catalog) {
+  activeCatalog = catalog;
+  document.querySelectorAll(".catalog-tab").forEach(button => {
+    button.classList.toggle("is-active", button.dataset.catalog === catalog);
+  });
+  renderActiveCatalogTable();
+}
+
+function renderActiveCatalogTable() {
+  if (!mockCatalog) return;
+  const definitions = {
+    routes: {
+      columns: [
+        ["origin", "起运地"], ["destination", "目的地"], ["transport_modes", "运输方式"],
+        ["product_count", "产品"], ["supplier_count", "供应商"], ["quote_count", "全部报价"],
+        ["current_quote_count", "当前有效"],
+      ],
+      rows: mockCatalog.routes,
+    },
+    customers: {
+      columns: [
+        ["name", "客户"], ["industry", "行业"], ["preferred_route", "主要路线"],
+        ["repeat_purchase_count", "复购"], ["price_sensitivity", "价格敏感"],
+        ["time_sensitivity", "时效敏感"], ["reliability_sensitivity", "可靠性敏感"],
+      ],
+      rows: mockCatalog.customers,
+    },
+    quotes: {
+      columns: [
+        ["quote_no", "报价编号"], ["route", "路线"], ["transport_mode", "方式"],
+        ["product_name", "产品"], ["supplier_name", "供应商"],
+        ["supplier_cost_amount", "采购金额"], ["valid_to", "有效至"], ["status", "状态"],
+      ],
+      rows: mockCatalog.quotes,
+    },
+    strategies: {
+      columns: [
+        ["name", "策略"], ["route", "适用路线"], ["objective", "目标"],
+        ["adjustment_rate", "价格调整"], ["valid_to", "有效至"],
+      ],
+      rows: mockCatalog.strategies,
+    },
+  };
+  const definition = definitions[activeCatalog];
+  document.getElementById("mockCatalogTable").innerHTML = renderCatalogTable(
+    definition.rows,
+    definition.columns,
+  );
+}
+
+function renderCatalogTable(rows, columns) {
+  if (!rows.length) return `<div class="empty">当前分类没有数据</div>`;
+  const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join("");
+  const body = rows.map(row => `
+    <tr>${columns.map(([key]) => `<td>${formatCatalogCell(key, row[key], row)}</td>`).join("")}</tr>`
+  ).join("");
+  return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
+}
+
+function formatCatalogCell(key, value, row) {
+  if (["price_sensitivity", "time_sensitivity", "reliability_sensitivity"].includes(key)) {
+    const width = Math.round(Number(value || 0) * 100);
+    return `<div class="sensitivity-cell"><span>${width}%</span><div class="sensitivity-track"><span style="width:${width}%"></span></div></div>`;
+  }
+  if (key === "supplier_cost_amount") {
+    return `${money(value)} ${escapeHtml(row.currency || "CNY")}${row.runtime_mock ? ` <span class="runtime-mark">内存</span>` : ""}`;
+  }
+  if (key === "status") {
+    const className = value === "当前有效" ? "status-current" : value === "已过期" ? "status-expired" : "";
+    return `<span class="${className}">${escapeHtml(value)}</span>`;
+  }
+  if (key === "adjustment_rate") return percent(value);
+  return escapeHtml(value ?? "");
+}
+
+async function generateMockBatch() {
+  if (!marketOptions?.routes?.length) return;
+  const button = document.getElementById("generateMockBtn");
+  const route = marketOptions.routes[Number(document.getElementById("mockRoute").value)];
+  if (!route) return;
+  button.disabled = true;
+  button.querySelector("span:first-child").textContent = "正在写入内存图";
+  document.getElementById("mockStatus").textContent = "正在生成供应商、产品、班期、报价和客户画像";
+  try {
+    const result = await fetchJson("/api/mock/generate", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        origin: route.origin,
+        destination: route.destination,
+        supplier_count: Number(document.getElementById("mockSupplierCount").value),
+        customer_count: Number(document.getElementById("mockCustomerCount").value),
+        evaluation_date: document.getElementById("evaluationDate").value || "2026-07-20",
+      }),
+    });
+    mockCatalog = result.catalog;
+    document.getElementById("mockStatus").textContent =
+      `批次 ${result.batch_id} 已增加 ${result.added_triples} 条内存三元组`;
+    await refreshMarketOptions();
+    renderMockCatalog();
+  } catch (error) {
+    document.getElementById("mockStatus").textContent = error.message;
+  } finally {
+    button.disabled = false;
+    button.querySelector("span:first-child").textContent = "添加一批 Mock 数据";
+  }
+}
+
+async function resetMockData() {
+  const button = document.getElementById("resetMockBtn");
+  button.disabled = true;
+  try {
+    mockCatalog = await fetchJson("/api/mock/reset", { method: "POST" });
+    document.getElementById("mockStatus").textContent = "动态数据已清空,已恢复本地 TTL 基础图";
+    await refreshMarketOptions();
+    await requestRecommendation(marketOptions.defaults);
+    renderMockCatalog();
+  } catch (error) {
+    document.getElementById("mockStatus").textContent = error.message;
+  } finally {
+    button.disabled = false;
+  }
+}
+
+async function refreshMarketOptions() {
+  marketOptions = await fetchJson("/api/market/form-options");
+  populateInquiryForm(marketOptions);
+}
+
+async function loadOperations() {
   await Promise.all([
     loadTable("orders", endpoints.orders, ["orderNo", "customerName", "status", "currentNodeName", "ownerName"]),
     loadTable("risks", endpoints.risks, ["orderNo", "riskName", "exceptionName", "affectedNodeName"]),
@@ -43,15 +536,26 @@ async function loadAll() {
     loadTable("cutoffs", endpoints.cutoffs, ["orderNo", "customsCutoffTime", "terminalCutoffTime", "vgmCutoffTime", "estimatedSailingTime"]),
     loadTable("exceptionMap", endpoints.exceptionMap, ["exceptionName", "affectedNodeName", "riskName"]),
   ]);
+  operationsLoaded = true;
 }
 
-async function fetchRows(url) {
-  const response = await fetch(url);
+async function fetchJson(url, options) {
+  const response = await fetch(url, options);
   if (!response.ok) {
-    const detail = await response.text();
-    throw new Error(detail || `HTTP ${response.status}`);
+    let detail = `请求失败:HTTP ${response.status}`;
+    try {
+      const payload = await response.json();
+      detail = payload.detail || detail;
+    } catch (_) {
+      // Preserve the HTTP message when the response is not JSON.
+    }
+    throw new Error(detail);
   }
-  const data = await response.json();
+  return response.json();
+}
+
+async function fetchRows(url) {
+  const data = await fetchJson(url);
   return data.rows || [];
 }
 
@@ -63,7 +567,7 @@ async function loadTable(id, url, columns) {
     if (counter) counter.textContent = rows.length;
     target.innerHTML = renderTable(rows, columns);
   } catch (error) {
-    target.innerHTML = `<div class="error">读取失败:请确认 Fuseki 正在运行,并且 gesli 数据集已导入。</div>`;
+    target.innerHTML = `<div class="error">本地RDF数据读取失败</div>`;
   }
 }
 
@@ -80,8 +584,7 @@ async function loadFlow() {
         <strong>${escapeHtml(row.sequence)}. ${escapeHtml(row.nodeName)}</strong>
         <span>${row.deadlineName ? `时限:${escapeHtml(row.deadlineName)}` : "无关键时限"}</span>
         <span>${row.nextNodeName ? `下一步:${escapeHtml(row.nextNodeName)}` : "流程结束"}</span>
-      </div>
-    `).join("");
+      </div>`).join("");
   } catch (error) {
     target.innerHTML = `<div class="error">流程读取失败</div>`;
   }
@@ -91,10 +594,7 @@ function renderTable(rows, columns) {
   if (!rows.length) return `<div class="empty">暂无数据</div>`;
   const head = columns.map(column => `<th>${labels[column] || column}</th>`).join("");
   const body = rows.map(row => `
-    <tr>
-      ${columns.map(column => `<td>${formatCell(column, row[column])}</td>`).join("")}
-    </tr>
-  `).join("");
+    <tr>${columns.map(column => `<td>${formatCell(column, row[column])}</td>`).join("")}</tr>`).join("");
   return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
 }
 
@@ -108,8 +608,24 @@ function formatCell(column, value) {
   return escapeHtml(value);
 }
 
+function money(value) {
+  return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value));
+}
+
+function numberFormat(value) {
+  return new Intl.NumberFormat("zh-CN").format(Number(value));
+}
+
+function percent(value) {
+  return `${Math.round(Number(value) * 100)}%`;
+}
+
+function shortDate(value) {
+  return value ? value.slice(5).replace("-", "/") : "待确认";
+}
+
 function escapeHtml(value) {
-  return String(value)
+  return String(value ?? "")
     .replaceAll("&", "&amp;")
     .replaceAll("<", "&lt;")
     .replaceAll(">", "&gt;")

+ 246 - 44
app/static/index.html

@@ -3,68 +3,270 @@
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>格士立青岛出口操作台</title>
-  <link rel="stylesheet" href="/static/styles.css">
+  <title>格士立 AI 市场中枢</title>
+  <link rel="stylesheet" href="/static/styles.css?v=7">
 </head>
 <body>
-  <header class="topbar">
-    <div>
-      <h1>青岛出口操作台</h1>
-      <p>订单、节点、异常、风险与关键时限</p>
+  <header class="app-header">
+    <div class="brand-block">
+      <span class="brand-mark" aria-hidden="true">G</span>
+      <div>
+        <strong>GESLI</strong>
+        <span>格士立物流科技</span>
+      </div>
+    </div>
+
+    <nav class="view-switcher" aria-label="工作台视图">
+      <button class="view-tab is-active" type="button" data-view="market">AI 市场中枢</button>
+      <button class="view-tab" type="button" data-view="mock">Mock 数据台</button>
+      <button class="view-tab" type="button" data-view="operations">履约操作台</button>
+    </nav>
+
+    <div class="system-status">
+      <span class="status-dot" aria-hidden="true"></span>
+      <span id="systemStatus">本地知识图谱</span>
     </div>
-    <button id="refreshBtn" type="button">刷新</button>
   </header>
 
-  <main class="layout">
-    <section class="panel">
-      <div class="panel-title">
-        <h2>订单状态</h2>
-        <span id="ordersCount">0</span>
-      </div>
-      <div id="orders" class="table-wrap"></div>
-    </section>
+  <main>
+    <section id="marketView" class="app-view is-active" aria-labelledby="marketTitle">
+      <aside class="inquiry-rail">
+        <div class="rail-heading">
+          <span class="section-index">01</span>
+          <div>
+            <h1 id="marketTitle">客户询价</h1>
+            <p id="inquiryState">待生成</p>
+          </div>
+        </div>
 
-    <section class="panel">
-      <div class="panel-title">
-        <h2>风险订单</h2>
-        <span id="risksCount">0</span>
-      </div>
-      <div id="risks" class="table-wrap"></div>
-    </section>
+        <form id="inquiryForm" class="inquiry-form">
+          <label class="field field-wide">
+            <span>客户</span>
+            <select id="customer" name="customer" required></select>
+          </label>
+
+          <label class="field">
+            <span>起运地</span>
+            <select id="origin" name="origin" required></select>
+          </label>
+
+          <label class="field">
+            <span>目的地</span>
+            <select id="destination" name="destination" required></select>
+          </label>
+
+          <label class="field">
+            <span>货物名称</span>
+            <input id="cargoName" name="cargo_name" type="text" required>
+          </label>
+
+          <label class="field">
+            <span>货量</span>
+            <input id="cargoVolume" name="cargo_volume" type="number" min="0.1" step="0.1" required>
+          </label>
+
+          <label class="field">
+            <span>询价日期</span>
+            <input id="evaluationDate" name="evaluation_date" type="date" required>
+          </label>
+
+          <label class="field">
+            <span>要求起运</span>
+            <input id="departureDate" name="requested_departure_date" type="date" required>
+          </label>
+
+          <label class="field">
+            <span>最长运输天数</span>
+            <input id="maxTransitDays" name="max_transit_days" type="number" min="1" step="1" required>
+          </label>
+
+          <label class="field">
+            <span>目标报价 CNY</span>
+            <input id="targetAmount" name="target_amount" type="number" min="1" step="100" required>
+          </label>
 
-    <section class="panel wide">
-      <div class="panel-title">
-        <h2>标准流程</h2>
-        <span>1-14</span>
+          <label class="field field-wide">
+            <span>路由偏好</span>
+            <select id="routeType" name="preferred_route_type"></select>
+          </label>
+
+          <label class="toggle-row field-wide">
+            <input id="dangerousGoods" name="dangerous_goods" type="checkbox">
+            <span class="toggle-control" aria-hidden="true"></span>
+            <span>危险品运输需求</span>
+          </label>
+
+          <button id="recommendBtn" class="primary-command field-wide" type="button">
+            <span>生成市场方案</span>
+            <span aria-hidden="true">→</span>
+          </button>
+        </form>
+
+        <div class="rail-foot">
+          <span>决策模式</span>
+          <strong>规则引擎 · 可解释</strong>
+        </div>
+      </aside>
+
+      <div class="market-workspace">
+        <section class="decision-header" aria-label="决策摘要">
+          <div>
+            <span class="section-kicker">MARKET DECISION / 实时</span>
+            <h2 id="decisionTitle">正在加载市场上下文</h2>
+          </div>
+          <button id="auditBtn" class="secondary-command" type="button" disabled>查看决策审计</button>
+        </section>
+
+        <section id="pipeline" class="pipeline" aria-label="决策管线"></section>
+
+        <section id="recommendationBand" class="recommendation-band is-loading" aria-live="polite">
+          <div class="loading-block">
+            <span class="loading-line"></span>
+            <span class="loading-line short"></span>
+          </div>
+        </section>
+
+        <section class="candidate-section">
+          <div class="section-heading">
+            <div>
+              <span class="section-index">02</span>
+              <h2>候选方案</h2>
+            </div>
+            <span id="candidateMeta" class="section-meta">等待计算</span>
+          </div>
+          <div id="candidateGrid" class="candidate-grid"></div>
+        </section>
+
+        <section class="rejected-section">
+          <div class="section-heading">
+            <div>
+              <span class="section-index">03</span>
+              <h2>硬条件过滤</h2>
+            </div>
+            <span id="rejectedMeta" class="section-meta">0 个淘汰候选</span>
+          </div>
+          <div id="rejectedList" class="rejected-list"></div>
+        </section>
       </div>
-      <div id="flow" class="timeline"></div>
     </section>
 
-    <section class="panel wide">
-      <div class="panel-title">
-        <h2>节点时间状态</h2>
-        <span id="nodeTimesCount">0</span>
+    <section id="mockView" class="app-view mock-view" aria-labelledby="mockTitle">
+      <div class="mock-heading">
+        <div>
+          <span class="section-kicker">LOCAL RDF / VOLATILE DATA</span>
+          <h1 id="mockTitle">Mock 数据台</h1>
+          <p>当前内存图中的客户、路线、产品、供应商、报价与市场策略</p>
+        </div>
+        <div class="mock-actions">
+          <span class="volatile-label">重启即清空动态数据</span>
+          <button id="resetMockBtn" class="secondary-command" type="button">重置内存数据</button>
+        </div>
       </div>
-      <div id="nodeTimes" class="table-wrap"></div>
+
+      <section id="mockSummary" class="data-summary" aria-label="本地数据统计"></section>
+
+      <section class="mock-generator" aria-labelledby="mockGeneratorTitle">
+        <div class="generator-copy">
+          <span class="section-index">01</span>
+          <div>
+            <h2 id="mockGeneratorTitle">动态补充样本</h2>
+            <p id="mockStatus">仅写入当前进程的 RDF Graph</p>
+          </div>
+        </div>
+        <div class="generator-controls">
+          <label class="compact-field route-field">
+            <span>目标路线</span>
+            <select id="mockRoute"></select>
+          </label>
+          <label class="compact-field">
+            <span>供应商数量</span>
+            <input id="mockSupplierCount" type="number" min="1" max="5" value="2">
+          </label>
+          <label class="compact-field">
+            <span>客户数量</span>
+            <input id="mockCustomerCount" type="number" min="0" max="3" value="1">
+          </label>
+          <button id="generateMockBtn" class="primary-command" type="button">
+            <span>添加一批 Mock 数据</span><span aria-hidden="true">+</span>
+          </button>
+        </div>
+      </section>
+
+      <section class="catalog-section">
+        <div class="catalog-heading">
+          <div>
+            <span class="section-index">02</span>
+            <h2>数据目录</h2>
+          </div>
+          <nav class="catalog-tabs" aria-label="Mock 数据分类">
+            <button class="catalog-tab is-active" type="button" data-catalog="routes">路线供给</button>
+            <button class="catalog-tab" type="button" data-catalog="customers">客户画像</button>
+            <button class="catalog-tab" type="button" data-catalog="quotes">供应商报价</button>
+            <button class="catalog-tab" type="button" data-catalog="strategies">市场策略</button>
+          </nav>
+        </div>
+        <div id="mockCatalogTable" class="catalog-table"></div>
+      </section>
+
+      <section class="source-section">
+        <div>
+          <span class="section-index">03</span>
+          <h2>加载来源</h2>
+        </div>
+        <div id="mockSources" class="source-list"></div>
+      </section>
     </section>
 
-    <section class="panel">
-      <div class="panel-title">
-        <h2>订单关键时限</h2>
-        <span id="cutoffsCount">0</span>
+    <section id="operationsView" class="app-view operations-view" aria-labelledby="operationsTitle">
+      <div class="operations-heading">
+        <div>
+          <span class="section-kicker">GLOBAL SHIPMENT / 履约</span>
+          <h1 id="operationsTitle">青岛出口操作台</h1>
+          <p>订单、节点、异常、风险与关键时限</p>
+        </div>
+        <button id="refreshBtn" class="secondary-command" type="button">刷新数据</button>
       </div>
-      <div id="cutoffs" class="table-wrap"></div>
-    </section>
 
-    <section class="panel">
-      <div class="panel-title">
-        <h2>异常影响</h2>
-        <span id="exceptionCount">0</span>
+      <div class="operations-layout">
+        <section class="panel">
+          <div class="panel-title"><h2>订单状态</h2><span id="ordersCount">0</span></div>
+          <div id="orders" class="table-wrap"></div>
+        </section>
+        <section class="panel">
+          <div class="panel-title"><h2>风险订单</h2><span id="risksCount">0</span></div>
+          <div id="risks" class="table-wrap"></div>
+        </section>
+        <section class="panel wide">
+          <div class="panel-title"><h2>标准流程</h2><span>1–14</span></div>
+          <div id="flow" class="timeline"></div>
+        </section>
+        <section class="panel wide">
+          <div class="panel-title"><h2>节点时间状态</h2><span id="nodeTimesCount">0</span></div>
+          <div id="nodeTimes" class="table-wrap"></div>
+        </section>
+        <section class="panel">
+          <div class="panel-title"><h2>订单关键时限</h2><span id="cutoffsCount">0</span></div>
+          <div id="cutoffs" class="table-wrap"></div>
+        </section>
+        <section class="panel">
+          <div class="panel-title"><h2>异常影响</h2><span id="exceptionMapCount">0</span></div>
+          <div id="exceptionMap" class="table-wrap"></div>
+        </section>
       </div>
-      <div id="exceptionMap" class="table-wrap"></div>
     </section>
   </main>
 
-  <script src="/static/app.js"></script>
+  <dialog id="auditDialog" class="audit-dialog">
+    <div class="dialog-heading">
+      <div>
+        <span class="section-kicker">DECISION TRACE</span>
+        <h2>决策审计</h2>
+      </div>
+      <button id="closeAuditBtn" class="icon-command" type="button" aria-label="关闭">×</button>
+    </div>
+    <div id="auditContent" class="audit-content"></div>
+  </dialog>
+
+  <script src="/static/app.js?v=7"></script>
 </body>
 </html>

+ 561 - 108
app/static/styles.css

@@ -1,172 +1,625 @@
 :root {
   color-scheme: light;
-  --bg: #f6f7f9;
-  --panel: #ffffff;
-  --line: #d8dde6;
-  --text: #17202a;
-  --muted: #667085;
-  --accent: #0f766e;
-  --danger: #b42318;
-  --warning: #b54708;
+  --canvas: #f2f4f1;
+  --surface: #ffffff;
+  --surface-soft: #f7f8f6;
+  --ink: #182321;
+  --ink-soft: #3f4d4a;
+  --muted: #71807c;
+  --line: #d8dfdc;
+  --line-strong: #bac7c2;
+  --teal: #0b766d;
+  --teal-dark: #075b54;
+  --teal-soft: #e2f1ed;
+  --amber: #b96f14;
+  --amber-soft: #fff1db;
+  --red: #ad3a32;
+  --red-soft: #fbe8e6;
+  --blue: #3b6f8f;
+  --blue-soft: #e7f0f5;
+  --shadow: 0 14px 32px rgba(24, 35, 33, 0.08);
 }
 
-* {
-  box-sizing: border-box;
-}
+* { box-sizing: border-box; }
+
+html { min-width: 320px; }
 
 body {
   margin: 0;
-  background: var(--bg);
-  color: var(--text);
-  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  background: var(--canvas);
+  color: var(--ink);
+  font-family: "Avenir Next", "PingFang SC", "Hiragino Sans GB", sans-serif;
+  letter-spacing: 0;
 }
 
-.topbar {
-  display: flex;
+button, input, select { font: inherit; letter-spacing: 0; }
+button { cursor: pointer; }
+h1, h2, h3, p { margin: 0; }
+
+.app-header {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  height: 68px;
+  display: grid;
+  grid-template-columns: 1fr auto 1fr;
   align-items: center;
-  justify-content: space-between;
-  gap: 16px;
-  padding: 18px 24px;
+  gap: 24px;
+  padding: 0 28px;
+  background: rgba(255, 255, 255, 0.96);
   border-bottom: 1px solid var(--line);
-  background: var(--panel);
 }
 
-h1, h2, p {
-  margin: 0;
+.brand-block { display: flex; align-items: center; gap: 11px; }
+.brand-mark {
+  width: 34px;
+  height: 34px;
+  display: grid;
+  place-items: center;
+  background: var(--ink);
+  color: white;
+  border-radius: 4px;
+  font-family: Georgia, serif;
+  font-size: 20px;
 }
+.brand-block div { display: grid; gap: 1px; }
+.brand-block strong { font-family: Georgia, serif; font-size: 15px; }
+.brand-block span:last-child { color: var(--muted); font-size: 11px; }
 
-h1 {
-  font-size: 22px;
+.view-switcher {
+  display: flex;
+  align-items: center;
+  padding: 3px;
+  background: var(--surface-soft);
+  border: 1px solid var(--line);
+  border-radius: 6px;
 }
-
-.topbar p {
-  margin-top: 4px;
+.view-tab {
+  min-height: 34px;
+  padding: 0 16px;
+  border: 0;
+  background: transparent;
   color: var(--muted);
-  font-size: 14px;
+  border-radius: 4px;
+  font-size: 13px;
 }
+.view-tab.is-active { background: var(--ink); color: white; }
 
-button {
-  border: 1px solid var(--accent);
-  background: var(--accent);
-  color: white;
-  padding: 8px 14px;
-  border-radius: 6px;
-  cursor: pointer;
+.system-status {
+  justify-self: end;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--ink-soft);
+  font-size: 12px;
+}
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #2d9b69;
+  box-shadow: 0 0 0 4px #e5f4eb;
 }
 
-.layout {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 16px;
-  padding: 16px;
+.app-view { display: none; }
+.app-view.is-active { display: grid; }
+
+#marketView {
+  grid-template-columns: 330px minmax(0, 1fr);
+  min-height: calc(100vh - 68px);
 }
 
-.panel {
-  min-height: 220px;
-  background: var(--panel);
-  border: 1px solid var(--line);
-  border-radius: 8px;
-  overflow: hidden;
+.inquiry-rail {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  padding: 26px 24px 20px;
+  background: var(--surface);
+  border-right: 1px solid var(--line);
 }
+.rail-heading { display: flex; gap: 12px; align-items: flex-start; }
+.section-index {
+  color: var(--teal);
+  font-family: Georgia, serif;
+  font-size: 12px;
+  font-weight: 700;
+}
+.rail-heading h1 { font-family: Georgia, "Songti SC", serif; font-size: 24px; font-weight: 600; }
+.rail-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
 
-.panel.wide {
-  grid-column: 1 / -1;
+.inquiry-form {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 14px 12px;
+  margin-top: 28px;
 }
+.field { min-width: 0; display: grid; gap: 6px; }
+.field-wide { grid-column: 1 / -1; }
+.field > span, .toggle-row > span:last-child {
+  color: var(--ink-soft);
+  font-size: 11px;
+  font-weight: 600;
+}
+.field input, .field select {
+  width: 100%;
+  height: 39px;
+  padding: 0 10px;
+  border: 1px solid var(--line-strong);
+  background: white;
+  color: var(--ink);
+  border-radius: 4px;
+  outline: none;
+  font-size: 13px;
+}
+.field input:focus, .field select:focus { border-color: var(--teal); box-shadow: 0 0 0 3px var(--teal-soft); }
 
-.panel-title {
+.toggle-row {
   display: flex;
-  justify-content: space-between;
   align-items: center;
-  padding: 12px 14px;
-  border-bottom: 1px solid var(--line);
+  gap: 9px;
+  min-height: 32px;
+  cursor: pointer;
 }
+.toggle-row input { position: absolute; opacity: 0; pointer-events: none; }
+.toggle-control {
+  position: relative;
+  width: 34px;
+  height: 19px;
+  flex: 0 0 auto;
+  border-radius: 10px;
+  background: #cbd3d0;
+  transition: background 160ms ease;
+}
+.toggle-control::after {
+  content: "";
+  position: absolute;
+  width: 15px;
+  height: 15px;
+  top: 2px;
+  left: 2px;
+  background: white;
+  border-radius: 50%;
+  transition: transform 160ms ease;
+}
+.toggle-row input:checked + .toggle-control { background: var(--teal); }
+.toggle-row input:checked + .toggle-control::after { transform: translateX(15px); }
 
-.panel-title h2 {
-  font-size: 16px;
+.primary-command, .secondary-command, .icon-command {
+  border: 0;
+  border-radius: 4px;
 }
+.primary-command {
+  height: 44px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 4px;
+  padding: 0 15px;
+  background: var(--teal);
+  color: white;
+  font-weight: 700;
+  font-size: 13px;
+  transition: background 160ms ease, transform 160ms ease;
+}
+.primary-command:hover { background: var(--teal-dark); transform: translateY(-1px); }
+.primary-command:disabled { cursor: wait; opacity: 0.65; transform: none; }
+.secondary-command {
+  min-height: 36px;
+  padding: 0 13px;
+  background: white;
+  border: 1px solid var(--line-strong);
+  color: var(--ink-soft);
+  font-size: 12px;
+  font-weight: 600;
+}
+.secondary-command:hover:not(:disabled) { border-color: var(--teal); color: var(--teal); }
+.secondary-command:disabled { cursor: default; opacity: 0.45; }
 
-.panel-title span {
+.rail-foot {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  margin-top: auto;
+  padding-top: 22px;
+  border-top: 1px solid var(--line);
   color: var(--muted);
-  font-size: 13px;
+  font-size: 11px;
 }
+.rail-foot strong { color: var(--teal-dark); font-weight: 600; }
 
-.table-wrap {
-  overflow: auto;
+.market-workspace { min-width: 0; padding: 28px 32px 48px; }
+.decision-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+}
+.section-kicker { color: var(--teal); font-size: 10px; font-weight: 800; }
+.decision-header h2 {
+  margin-top: 7px;
+  font-family: Georgia, "Songti SC", serif;
+  font-size: 26px;
+  font-weight: 500;
 }
 
-table {
-  width: 100%;
-  border-collapse: collapse;
-  font-size: 13px;
+.pipeline {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-top: 24px;
+  border: 1px solid var(--line);
+  background: var(--surface);
 }
+.pipeline-step {
+  position: relative;
+  min-height: 70px;
+  display: grid;
+  align-content: center;
+  gap: 4px;
+  padding: 12px 16px;
+  border-right: 1px solid var(--line);
+}
+.pipeline-step:last-child { border-right: 0; }
+.pipeline-step span { color: var(--muted); font-size: 10px; text-transform: uppercase; }
+.pipeline-step strong { font-size: 18px; }
+.pipeline-step em { color: var(--ink-soft); font-size: 11px; font-style: normal; }
+.pipeline-step.is-accent strong { color: var(--teal); }
+.pipeline-step.is-rejected strong { color: var(--red); }
 
-th, td {
-  padding: 10px 12px;
-  border-bottom: 1px solid var(--line);
-  text-align: left;
-  vertical-align: top;
-  white-space: nowrap;
+.recommendation-band {
+  min-height: 196px;
+  display: grid;
+  grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.7fr);
+  gap: 28px;
+  margin-top: 16px;
+  padding: 28px;
+  background: var(--ink);
+  color: white;
+  border-radius: 6px;
+  overflow: hidden;
+}
+.recommendation-copy { align-self: center; }
+.recommendation-label { color: #80cfc2; font-size: 10px; font-weight: 800; }
+.recommendation-copy h2 {
+  max-width: 760px;
+  margin-top: 9px;
+  font-family: Georgia, "Songti SC", serif;
+  font-size: 29px;
+  font-weight: 500;
+  line-height: 1.35;
 }
+.recommendation-copy p { max-width: 780px; margin-top: 13px; color: #c7d1ce; font-size: 13px; line-height: 1.7; }
+.recommendation-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 17px; }
+.recommendation-tags span { padding: 5px 8px; border: 1px solid #51615d; border-radius: 3px; color: #dce4e1; font-size: 10px; }
+.quote-block {
+  display: grid;
+  align-content: center;
+  padding-left: 28px;
+  border-left: 1px solid #44524f;
+}
+.quote-block > span { color: #9baba7; font-size: 10px; }
+.quote-amount { margin-top: 7px; font-family: Georgia, serif; font-size: 37px; }
+.quote-amount small { margin-left: 5px; color: #9baba7; font: 11px "Avenir Next", sans-serif; }
+.quote-range { margin-top: 8px; color: #c7d1ce; font-size: 11px; }
+.confidence-row { display: flex; align-items: center; gap: 10px; margin-top: 18px; color: #c7d1ce; font-size: 11px; }
+.confidence-track { flex: 1; height: 4px; background: #45534f; }
+.confidence-track span { display: block; height: 100%; background: #65c2b3; }
 
-th {
-  background: #f9fafb;
-  color: #344054;
-  font-weight: 600;
+.recommendation-band.is-loading { grid-template-columns: 1fr; }
+.loading-block { align-self: center; display: grid; gap: 12px; }
+.loading-line { width: 62%; height: 17px; background: #34423f; animation: pulse 1.4s infinite; }
+.loading-line.short { width: 38%; }
+@keyframes pulse { 50% { opacity: 0.45; } }
+
+.candidate-section, .rejected-section { margin-top: 34px; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 13px; }
+.section-heading > div { display: flex; align-items: center; gap: 10px; }
+.section-heading h2 { font-size: 16px; }
+.section-meta { color: var(--muted); font-size: 11px; }
+
+.candidate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.candidate-card {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-top: 3px solid var(--teal);
+  border-radius: 5px;
+  animation: rise-in 280ms both;
 }
+.candidate-card.type-economy { border-top-color: var(--blue); }
+.candidate-card.type-speed { border-top-color: var(--amber); }
+@keyframes rise-in { from { opacity: 0; transform: translateY(8px); } }
+.candidate-head { padding: 16px 16px 13px; border-bottom: 1px solid var(--line); }
+.candidate-type-row { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
+.candidate-type { color: var(--teal); font-size: 11px; font-weight: 800; }
+.type-economy .candidate-type { color: var(--blue); }
+.type-speed .candidate-type { color: var(--amber); }
+.candidate-rank { color: var(--muted); font-family: Georgia, serif; font-size: 12px; }
+.candidate-head h3 { margin-top: 9px; min-height: 40px; font-size: 14px; line-height: 1.45; }
+.candidate-supplier { margin-top: 5px; color: var(--muted); font-size: 11px; }
+.candidate-facts { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--line); }
+.candidate-facts div { padding: 11px 10px; border-right: 1px solid var(--line); }
+.candidate-facts div:last-child { border-right: 0; }
+.candidate-facts span { display: block; color: var(--muted); font-size: 9px; }
+.candidate-facts strong { display: block; margin-top: 4px; font-size: 12px; }
+.score-list { display: grid; gap: 9px; padding: 14px 16px; }
+.score-row { display: grid; grid-template-columns: 58px 1fr 30px; align-items: center; gap: 8px; font-size: 10px; color: var(--ink-soft); }
+.score-track { height: 4px; background: #e5eae7; }
+.score-track span { display: block; height: 100%; background: var(--teal); }
+.score-row strong { text-align: right; font-size: 10px; }
+.candidate-reason { min-height: 72px; padding: 12px 16px; background: var(--surface-soft); color: var(--ink-soft); font-size: 11px; line-height: 1.55; }
+.candidate-price {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 12px;
+  margin-top: auto;
+  padding: 14px 16px 16px;
+}
+.candidate-price span { display: block; color: var(--muted); font-size: 9px; }
+.candidate-price strong { display: block; margin-top: 3px; font-family: Georgia, serif; font-size: 22px; }
+.risk-label { max-width: 108px; padding: 4px 6px; border-radius: 3px; background: var(--amber-soft); color: var(--amber); font-size: 9px; text-align: right; }
+.risk-label.is-danger { background: var(--red-soft); color: var(--red); }
+.risk-label.is-safe { background: var(--teal-soft); color: var(--teal-dark); }
 
-.timeline {
+.rejected-list { border-top: 1px solid var(--line-strong); }
+.rejected-row {
   display: grid;
-  grid-template-columns: repeat(7, minmax(120px, 1fr));
-  gap: 10px;
-  padding: 14px;
+  grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 0.8fr) minmax(240px, 2fr);
+  gap: 18px;
+  align-items: center;
+  min-height: 58px;
+  padding: 10px 4px;
+  border-bottom: 1px solid var(--line);
+  font-size: 11px;
 }
+.rejected-row strong { font-size: 12px; }
+.rejected-row span { color: var(--muted); }
+.rejected-reasons { display: flex; flex-wrap: wrap; gap: 5px; }
+.rejected-reasons em { padding: 4px 6px; background: var(--red-soft); color: var(--red); border-radius: 3px; font-style: normal; font-size: 9px; }
 
-.step {
+.mock-view { padding: 30px 32px 48px; }
+.mock-view.is-active { display: block; }
+.mock-heading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+}
+.mock-heading h1 {
+  margin-top: 6px;
+  font-family: Georgia, "Songti SC", serif;
+  font-size: 28px;
+  font-weight: 500;
+}
+.mock-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
+.mock-actions { display: flex; align-items: center; gap: 12px; }
+.volatile-label {
+  padding: 5px 7px;
+  border-radius: 3px;
+  background: var(--amber-soft);
+  color: var(--amber);
+  font-size: 10px;
+  font-weight: 700;
+}
+.data-summary {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(0, 1fr));
+  margin-top: 24px;
+  background: var(--surface);
   border: 1px solid var(--line);
-  border-left: 4px solid var(--accent);
-  border-radius: 6px;
-  padding: 10px;
-  background: #fbfcfd;
-  min-height: 74px;
 }
+.data-stat {
+  min-width: 0;
+  min-height: 86px;
+  display: grid;
+  align-content: center;
+  gap: 4px;
+  padding: 13px 15px;
+  border-right: 1px solid var(--line);
+}
+.data-stat:last-child { border-right: 0; }
+.data-stat span { color: var(--muted); font-size: 9px; }
+.data-stat strong { font-family: Georgia, serif; font-size: 23px; font-weight: 500; }
+.data-stat em { color: var(--teal); font-size: 9px; font-style: normal; }
+.data-stat.has-memory strong { color: var(--amber); }
 
-.step strong {
-  display: block;
-  font-size: 14px;
+.mock-generator {
+  display: grid;
+  grid-template-columns: minmax(220px, 0.75fr) minmax(0, 2fr);
+  gap: 28px;
+  align-items: end;
+  margin-top: 18px;
+  padding: 22px;
+  background: var(--ink);
+  color: white;
+  border-radius: 5px;
+}
+.generator-copy { display: flex; align-items: flex-start; gap: 10px; }
+.generator-copy h2 { font-size: 16px; }
+.generator-copy p { margin-top: 5px; color: #aebbb7; font-size: 10px; }
+.generator-controls {
+  display: grid;
+  grid-template-columns: minmax(220px, 1.5fr) minmax(100px, 0.5fr) minmax(100px, 0.5fr) minmax(190px, 0.9fr);
+  gap: 10px;
+  align-items: end;
+}
+.compact-field { min-width: 0; display: grid; gap: 6px; }
+.compact-field span { color: #aebbb7; font-size: 9px; font-weight: 700; }
+.compact-field select, .compact-field input {
+  width: 100%;
+  height: 39px;
+  padding: 0 9px;
+  border: 1px solid #4c5a57;
+  border-radius: 4px;
+  outline: 0;
+  background: #23302e;
+  color: white;
+  font-size: 11px;
 }
+.mock-generator .primary-command { margin: 0; }
 
-.step span {
-  display: block;
-  margin-top: 6px;
+.catalog-section { margin-top: 34px; }
+.catalog-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  margin-bottom: 12px;
+}
+.catalog-heading > div, .source-section > div:first-child {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.catalog-heading h2, .source-section h2 { font-size: 16px; }
+.catalog-tabs {
+  display: flex;
+  padding: 3px;
+  border: 1px solid var(--line);
+  border-radius: 5px;
+  background: var(--surface);
+}
+.catalog-tab {
+  min-height: 30px;
+  padding: 0 11px;
+  border: 0;
+  border-radius: 3px;
+  background: transparent;
   color: var(--muted);
-  font-size: 12px;
+  font-size: 10px;
 }
-
-.badge-danger {
-  color: var(--danger);
-  font-weight: 600;
+.catalog-tab.is-active { background: var(--teal-soft); color: var(--teal-dark); font-weight: 700; }
+.catalog-table {
+  min-height: 260px;
+  overflow: auto;
+  border: 1px solid var(--line);
+  background: var(--surface);
 }
+.catalog-table table { font-size: 11px; }
+.catalog-table tbody tr:hover { background: #f2f7f5; }
+.catalog-table .status-current { color: var(--teal); font-weight: 700; }
+.catalog-table .status-expired { color: var(--red); font-weight: 700; }
+.runtime-mark { color: var(--amber); font-size: 9px; font-weight: 700; }
+.sensitivity-cell { min-width: 110px; }
+.sensitivity-track { width: 86px; height: 4px; background: #e5eae7; }
+.sensitivity-track span { display: block; height: 100%; background: var(--blue); }
 
-.badge-warning {
-  color: var(--warning);
-  font-weight: 600;
+.source-section { margin-top: 30px; }
+.source-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
+.source-list code {
+  padding: 7px 9px;
+  border: 1px solid var(--line);
+  background: var(--surface);
+  color: var(--ink-soft);
+  font-size: 9px;
 }
 
-.empty, .error {
-  padding: 16px;
-  color: var(--muted);
+.operations-view { padding: 30px 32px 48px; }
+.operations-view.is-active { display: block; }
+.operations-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
+.operations-heading h1 { margin-top: 6px; font-family: Georgia, "Songti SC", serif; font-size: 28px; font-weight: 500; }
+.operations-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
+.operations-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 24px; }
+.panel { min-height: 220px; background: var(--surface); border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
+.panel.wide { grid-column: 1 / -1; }
+.panel-title { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
+.panel-title h2 { font-size: 14px; }
+.panel-title span { color: var(--muted); font-size: 11px; }
+.table-wrap { overflow: auto; }
+table { width: 100%; border-collapse: collapse; font-size: 11px; }
+th, td { padding: 9px 11px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; white-space: nowrap; }
+th { background: var(--surface-soft); color: var(--ink-soft); font-weight: 700; }
+.timeline { display: grid; grid-template-columns: repeat(7, minmax(120px, 1fr)); gap: 8px; padding: 14px; }
+.step { min-height: 76px; padding: 10px; background: var(--surface-soft); border-left: 3px solid var(--teal); }
+.step strong { display: block; font-size: 11px; }
+.step span { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; }
+.badge-danger { color: var(--red); font-weight: 700; }
+.badge-warning { color: var(--amber); font-weight: 700; }
+.empty, .error { padding: 16px; color: var(--muted); font-size: 12px; }
+.error { color: var(--red); }
+
+.audit-dialog {
+  width: min(720px, calc(100vw - 32px));
+  max-height: min(780px, calc(100vh - 48px));
+  padding: 0;
+  border: 1px solid var(--line-strong);
+  border-radius: 6px;
+  box-shadow: var(--shadow);
 }
+.audit-dialog::backdrop { background: rgba(24, 35, 33, 0.42); }
+.dialog-heading { position: sticky; top: 0; display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; padding: 20px 22px; background: white; border-bottom: 1px solid var(--line); }
+.dialog-heading h2 { margin-top: 5px; font-family: Georgia, "Songti SC", serif; font-size: 22px; font-weight: 500; }
+.icon-command { width: 34px; height: 34px; background: var(--surface-soft); border: 1px solid var(--line); color: var(--ink-soft); font-size: 22px; }
+.audit-content { padding: 22px; }
+.audit-grid { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--line); }
+.audit-item { min-height: 72px; padding: 12px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
+.audit-item:nth-child(2n) { border-right: 0; }
+.audit-item span { color: var(--muted); font-size: 9px; }
+.audit-item strong { display: block; margin-top: 5px; font-size: 12px; overflow-wrap: anywhere; }
+.audit-sources { margin-top: 20px; }
+.audit-sources h3 { font-size: 12px; }
+.audit-sources code { display: block; margin-top: 8px; padding: 9px 10px; background: var(--surface-soft); color: var(--ink-soft); font-size: 10px; overflow-wrap: anywhere; }
 
-.error {
-  color: var(--danger);
+@media (max-width: 1120px) {
+  #marketView { grid-template-columns: 290px minmax(0, 1fr); }
+  .market-workspace { padding-left: 22px; padding-right: 22px; }
+  .candidate-grid { grid-template-columns: 1fr; }
+  .candidate-card { display: grid; grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.2fr); }
+  .candidate-head { grid-row: 1 / span 2; }
+  .candidate-facts { border-left: 1px solid var(--line); }
+  .score-list { border-left: 1px solid var(--line); }
+  .candidate-reason { grid-column: 1 / -1; min-height: 0; }
+  .candidate-price { grid-column: 1 / -1; }
+  .data-summary { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+  .data-stat:nth-child(4) { border-right: 0; }
+  .data-stat:nth-child(-n+4) { border-bottom: 1px solid var(--line); }
+  .generator-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
 }
 
-@media (max-width: 900px) {
-  .layout {
-    grid-template-columns: 1fr;
-  }
+@media (max-width: 780px) {
+  .app-header { position: static; height: auto; grid-template-columns: 1fr auto; padding: 13px 16px; }
+  .view-switcher { grid-column: 1 / -1; grid-row: 2; }
+  .view-tab { flex: 1; }
+  .system-status span:last-child { display: none; }
+  #marketView { grid-template-columns: 1fr; }
+  .inquiry-rail { border-right: 0; border-bottom: 1px solid var(--line); padding: 22px 18px; }
+  .inquiry-form { grid-template-columns: minmax(0, 1fr); }
+  .field-wide { grid-column: auto; }
+  .rail-foot { margin-top: 24px; }
+  .market-workspace { padding: 22px 16px 38px; }
+  .decision-header { align-items: center; }
+  .decision-header h2 { font-size: 21px; }
+  .pipeline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .pipeline-step:nth-child(2) { border-right: 0; }
+  .pipeline-step:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
+  .recommendation-band { grid-template-columns: 1fr; padding: 22px; }
+  .recommendation-copy h2 { font-size: 23px; }
+  .quote-block { padding: 18px 0 0; border-left: 0; border-top: 1px solid #44524f; }
+  .candidate-card { display: flex; }
+  .candidate-head { grid-row: auto; }
+  .candidate-facts, .score-list { border-left: 0; }
+  .rejected-row { grid-template-columns: 1fr; gap: 5px; padding: 13px 2px; }
+  .mock-view { padding: 24px 16px 38px; }
+  .mock-heading { display: grid; }
+  .mock-actions { justify-content: space-between; }
+  .data-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .data-stat, .data-stat:nth-child(4) { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
+  .data-stat:nth-child(2n) { border-right: 0; }
+  .data-stat:last-child { border-bottom: 0; }
+  .mock-generator { grid-template-columns: 1fr; padding: 18px; }
+  .generator-controls { grid-template-columns: 1fr 1fr; }
+  .route-field, .mock-generator .primary-command { grid-column: 1 / -1; }
+  .catalog-heading { display: grid; }
+  .catalog-tabs { overflow-x: auto; }
+  .catalog-tab { flex: 0 0 auto; }
+  .operations-view { padding: 24px 16px 38px; }
+  .operations-layout { grid-template-columns: 1fr; }
+  .panel.wide { grid-column: auto; }
+  .timeline { grid-template-columns: 1fr; }
+}
 
-  .timeline {
-    grid-template-columns: 1fr;
-  }
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
 }

+ 17 - 0
config/market-strategies.json

@@ -0,0 +1,17 @@
+{
+  "solution_price_adjustments": {
+    "经济方案": -700,
+    "均衡方案": 0,
+    "时效方案": 1400
+  },
+  "quote_rounding": 100,
+  "allowed_lower_delta": 1000,
+  "allowed_upper_delta": 3000,
+  "component_product_codes_by_origin": {
+    "青岛港": [
+      "TRUCKA-QD-FULLTRUCK",
+      "QD-CUSTOMS-EXPORT-SUPERVISION"
+    ],
+    "__default__": []
+  }
+}

+ 205 - 0
data/ai-market-demo.ttl

@@ -0,0 +1,205 @@
+@prefix gesli: <https://gesli.example/ontology#> .
+@prefix sample: <https://gesli.example/sample#> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+
+# Customer preference signals used by the deterministic recommendation engine.
+sample:HaierTradeProfile
+    gesli:customerPriceSensitivity "0.45"^^xsd:decimal ;
+    gesli:customerTimeSensitivity "0.75"^^xsd:decimal ;
+    gesli:customerReliabilitySensitivity "0.90"^^xsd:decimal .
+
+sample:HisenseTradeProfile
+    gesli:customerPriceSensitivity "0.80"^^xsd:decimal ;
+    gesli:customerTimeSensitivity "0.50"^^xsd:decimal ;
+    gesli:customerReliabilitySensitivity "0.70"^^xsd:decimal .
+
+sample:MarketBenchmark_QD_LA_202607 a gesli:MarketPriceBenchmark ;
+    rdfs:label "青岛-洛杉矶2026年7月市场基准"@zh ;
+    gesli:marketBenchmarkAmount "55000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "洛杉矶港" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+sample:Strategy_QD_LA_Scale_Active a gesli:ScalePricingStrategy ;
+    rdfs:label "青岛-洛杉矶规模价格策略(启用)"@zh ;
+    gesli:strategyCode "STR-QD-LA-SCALE-ACTIVE" ;
+    gesli:strategyName "青岛-洛杉矶规模价格策略" ;
+    gesli:strategyObjective "聚合青岛出口货量并保持客户价格竞争力" ;
+    gesli:strategyEnabled true ;
+    gesli:strategyStatus "已启用" ;
+    gesli:strategyAdjustmentRate "-0.035"^^xsd:decimal ;
+    gesli:strategyScopeOrigin "青岛港" ;
+    gesli:strategyScopeDestination "洛杉矶港" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+# Current schedules and quotes for the two existing Maersk sales channels.
+sample:Schedule_Maersk_QD_LA_20260728 a gesli:ScheduledServiceInstance ;
+    rdfs:label "马士基青岛-洛杉矶2026-07-28班期"@zh ;
+    gesli:scheduleCode "SCH-MSK-QD-LA-20260728" ;
+    gesli:scheduleOfProduct sample:Product_Maersk_QD_LA_DirectFcl ;
+    gesli:documentCutoffTime "2026-07-26T16:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-28T22:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-12T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "15"^^xsd:decimal .
+
+sample:Quote_Maersk_QD_LA_20260720 a gesli:SupplierQuote ;
+    rdfs:label "马士基青岛分支机构当前报价"@zh ;
+    gesli:quoteNo "SQ-MSK-20260720-001" ;
+    gesli:quotedBySupplier sample:MaerskQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_MaerskBranch_Maersk_QD_LA ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Maersk_QD_LA_20260728 ;
+    gesli:supplierCostAmount "45500"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+sample:Quote_AgentA_Maersk_QD_LA_20260720 a gesli:SupplierQuote ;
+    rdfs:label "青岛订舱代理A当前报价"@zh ;
+    gesli:quoteNo "SQ-AGENTA-MSK-20260720-001" ;
+    gesli:quotedBySupplier sample:QingdaoOceanBookingAgentA ;
+    gesli:quotedForOffering sample:Offering_AgentA_Maersk_QD_LA ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Maersk_QD_LA_20260728 ;
+    gesli:supplierCostAmount "44300"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+# A faster, more reliable alternative for the same route.
+sample:Oocl a gesli:ServiceBrand ;
+    rdfs:label "东方海外"@zh ;
+    gesli:brandCode "OOCL" ;
+    gesli:brandName "东方海外" .
+
+sample:OoclQingdaoBranch a gesli:SupplierLocalUnit, gesli:Carrier ;
+    rdfs:label "东方海外青岛分支机构"@zh ;
+    gesli:supplierNature "商务型" ;
+    gesli:platformSupplierType "船公司" ;
+    gesli:serviceCountry "CN" ;
+    gesli:serviceCity "Qingdao" ;
+    gesli:servicePort "青岛港" ;
+    gesli:isActivePartner true ;
+    gesli:authorizedForServiceBrand sample:Oocl ;
+    gesli:hasProductOffering sample:Offering_Oocl_QD_LA_Express .
+
+sample:OoclQdLaPerformance a gesli:ProductPerformanceProfile ;
+    rdfs:label "东方海外青岛-洛杉矶快线履约画像"@zh ;
+    gesli:etdAtdOnTimeRate "0.96"^^xsd:decimal ;
+    gesli:etaAtaOnTimeRate "0.94"^^xsd:decimal ;
+    gesli:scheduleDelayRate "0.04"^^xsd:decimal ;
+    gesli:scheduleChangeRate "0.03"^^xsd:decimal .
+
+sample:Product_Oocl_QD_LA_Express a gesli:ContainerOceanProduct ;
+    rdfs:label "东方海外青岛-洛杉矶直航快线"@zh ;
+    gesli:productCode "OOCL-QD-LA-EXPRESS" ;
+    gesli:productName "青岛-洛杉矶直航快线40HQ" ;
+    gesli:hasServiceBrand sample:Oocl ;
+    gesli:hasProductPerformanceProfile sample:OoclQdLaPerformance ;
+    gesli:transportMode "海运" ;
+    gesli:transportSubMode "集装箱海运" ;
+    gesli:serviceForm "包柜" ;
+    gesli:routeType "直航" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "洛杉矶港" ;
+    gesli:estimatedTransitDays "13"^^xsd:decimal .
+
+sample:Offering_Oocl_QD_LA_Express a gesli:SupplierProductOffering ;
+    rdfs:label "东方海外青岛分支机构销售青岛-洛杉矶快线"@zh ;
+    gesli:offeringCode "OFF-OOCL-QD-LA-EXPRESS" ;
+    gesli:offeredBySupplier sample:OoclQingdaoBranch ;
+    gesli:offeringProduct sample:Product_Oocl_QD_LA_Express ;
+    gesli:offeringChannel "分公司直营" ;
+    gesli:agencyLevel "产品品牌本地分支机构" ;
+    gesli:capacityCommitment "每周保证12TEU" ;
+    gesli:capacityAvailable true ;
+    gesli:supportsDangerousGoods false ;
+    gesli:bookingSuccessRate "0.98"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.97"^^xsd:decimal ;
+    gesli:serviceLevelCommitment "工作日1小时内确认订舱" .
+
+sample:Schedule_Oocl_QD_LA_20260729 a gesli:ScheduledServiceInstance ;
+    rdfs:label "东方海外青岛-洛杉矶2026-07-29快线"@zh ;
+    gesli:scheduleCode "SCH-OOCL-QD-LA-20260729" ;
+    gesli:scheduleOfProduct sample:Product_Oocl_QD_LA_Express ;
+    gesli:documentCutoffTime "2026-07-27T14:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-29T18:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-11T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "13"^^xsd:decimal .
+
+sample:Quote_Oocl_QD_LA_20260720 a gesli:SupplierQuote ;
+    rdfs:label "东方海外青岛-洛杉矶快线当前报价"@zh ;
+    gesli:quoteNo "SQ-OOCL-20260720-001" ;
+    gesli:quotedBySupplier sample:OoclQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_Oocl_QD_LA_Express ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Oocl_QD_LA_20260729 ;
+    gesli:supplierCostAmount "48600"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+# An intentionally expired economy candidate demonstrates hard-condition rejection.
+sample:Evergreen a gesli:ServiceBrand ;
+    rdfs:label "长荣海运"@zh ;
+    gesli:brandCode "EVERGREEN" ;
+    gesli:brandName "长荣海运" .
+
+sample:QingdaoEconomyAgent a gesli:ShippingAgencyCompany ;
+    rdfs:label "青岛经济航线代理"@zh ;
+    gesli:supplierNature "商务型" ;
+    gesli:platformSupplierType "船代公司" ;
+    gesli:serviceCountry "CN" ;
+    gesli:serviceCity "Qingdao" ;
+    gesli:servicePort "青岛港" ;
+    gesli:isActivePartner true ;
+    gesli:authorizedForServiceBrand sample:Evergreen ;
+    gesli:hasProductOffering sample:Offering_Evergreen_QD_LA_Economy .
+
+sample:Product_Evergreen_QD_LA_Economy a gesli:ContainerOceanProduct ;
+    rdfs:label "长荣海运青岛-洛杉矶中转经济线"@zh ;
+    gesli:productCode "EGL-QD-LA-ECONOMY" ;
+    gesli:productName "青岛-洛杉矶中转经济线40HQ" ;
+    gesli:hasServiceBrand sample:Evergreen ;
+    gesli:transportMode "海运" ;
+    gesli:transportSubMode "集装箱海运" ;
+    gesli:serviceForm "包柜" ;
+    gesli:routeType "中转" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "洛杉矶港" ;
+    gesli:estimatedTransitDays "22"^^xsd:decimal .
+
+sample:Offering_Evergreen_QD_LA_Economy a gesli:SupplierProductOffering ;
+    rdfs:label "青岛经济航线代理销售长荣经济线"@zh ;
+    gesli:offeringCode "OFF-EGL-QD-LA-ECONOMY" ;
+    gesli:offeredBySupplier sample:QingdaoEconomyAgent ;
+    gesli:offeringProduct sample:Product_Evergreen_QD_LA_Economy ;
+    gesli:offeringChannel "订舱代理" ;
+    gesli:agencyLevel "二级代理" ;
+    gesli:capacityCommitment "以单票确认为准" ;
+    gesli:capacityAvailable true ;
+    gesli:supportsDangerousGoods false ;
+    gesli:bookingSuccessRate "0.84"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.82"^^xsd:decimal .
+
+sample:Schedule_Evergreen_QD_LA_20260730 a gesli:ScheduledServiceInstance ;
+    rdfs:label "长荣青岛-洛杉矶2026-07-30中转班期"@zh ;
+    gesli:scheduleCode "SCH-EGL-QD-LA-20260730" ;
+    gesli:scheduleOfProduct sample:Product_Evergreen_QD_LA_Economy ;
+    gesli:documentCutoffTime "2026-07-28T12:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-30T20:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-21T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "22"^^xsd:decimal .
+
+sample:Quote_Evergreen_QD_LA_Expired a gesli:SupplierQuote ;
+    rdfs:label "长荣青岛-洛杉矶已过期经济报价"@zh ;
+    gesli:quoteNo "SQ-EGL-20260710-EXPIRED" ;
+    gesli:quotedBySupplier sample:QingdaoEconomyAgent ;
+    gesli:quotedForOffering sample:Offering_Evergreen_QD_LA_Economy ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Evergreen_QD_LA_20260730 ;
+    gesli:supplierCostAmount "42000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-10"^^xsd:date ;
+    gesli:validTo "2026-07-18"^^xsd:date .
+

+ 492 - 0
data/ai-market-scenarios.ttl

@@ -0,0 +1,492 @@
+@prefix gesli: <https://gesli.example/ontology#> .
+@prefix sample: <https://gesli.example/sample#> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+
+# Additional customer profiles expose different recommendation preferences.
+sample:Midea a gesli:Customer ;
+    rdfs:label "美的集团"@zh ;
+    gesli:hasTradeProfile sample:MideaTradeProfile ;
+    gesli:hasCreditProfile sample:MideaCreditProfile ;
+    gesli:hasServiceProfile sample:MideaServiceProfile .
+
+sample:MideaTradeProfile a gesli:CustomerTradeProfile ;
+    gesli:industry "家电" ;
+    gesli:routeName "青岛-汉堡" ;
+    gesli:repeatPurchaseCount 14 ;
+    gesli:quoteWinRate "0.61"^^xsd:decimal ;
+    gesli:customerPriceSensitivity "0.72"^^xsd:decimal ;
+    gesli:customerTimeSensitivity "0.60"^^xsd:decimal ;
+    gesli:customerReliabilitySensitivity "0.82"^^xsd:decimal .
+
+sample:MideaCreditProfile a gesli:CustomerCreditProfile ;
+    gesli:overdueDays 0 ;
+    gesli:creditTermDays 45 .
+
+sample:MideaServiceProfile a gesli:CustomerServiceProfile ;
+    gesli:notificationRequirement "每日节点汇总" ;
+    gesli:documentRequirement "欧盟进口资料提前校验" ;
+    gesli:exceptionResponseRequirement "2小时内响应" .
+
+sample:Sailun a gesli:Customer ;
+    rdfs:label "赛轮集团"@zh ;
+    gesli:hasTradeProfile sample:SailunTradeProfile ;
+    gesli:hasCreditProfile sample:SailunCreditProfile ;
+    gesli:hasServiceProfile sample:SailunServiceProfile .
+
+sample:SailunTradeProfile a gesli:CustomerTradeProfile ;
+    gesli:industry "橡胶轮胎" ;
+    gesli:routeName "青岛-洛杉矶" ;
+    gesli:repeatPurchaseCount 24 ;
+    gesli:quoteWinRate "0.73"^^xsd:decimal ;
+    gesli:customerPriceSensitivity "0.55"^^xsd:decimal ;
+    gesli:customerTimeSensitivity "0.55"^^xsd:decimal ;
+    gesli:customerReliabilitySensitivity "0.95"^^xsd:decimal .
+
+sample:SailunCreditProfile a gesli:CustomerCreditProfile ;
+    gesli:overdueDays 1 ;
+    gesli:creditTermDays 30 .
+
+sample:SailunServiceProfile a gesli:CustomerServiceProfile ;
+    gesli:notificationRequirement "危险品申报和放行节点即时通知" ;
+    gesli:documentRequirement "危包证与MSDS双重校验" ;
+    gesli:exceptionResponseRequirement "1小时内响应" .
+
+sample:QingdaoMedTech a gesli:Customer ;
+    rdfs:label "青岛医疗器械科技"@zh ;
+    gesli:hasTradeProfile sample:QingdaoMedTechTradeProfile ;
+    gesli:hasCreditProfile sample:QingdaoMedTechCreditProfile ;
+    gesli:hasServiceProfile sample:QingdaoMedTechServiceProfile .
+
+sample:QingdaoMedTechTradeProfile a gesli:CustomerTradeProfile ;
+    gesli:industry "医疗器械" ;
+    gesli:routeName "青岛胶东机场-法兰克福机场" ;
+    gesli:repeatPurchaseCount 8 ;
+    gesli:quoteWinRate "0.58"^^xsd:decimal ;
+    gesli:customerPriceSensitivity "0.35"^^xsd:decimal ;
+    gesli:customerTimeSensitivity "0.95"^^xsd:decimal ;
+    gesli:customerReliabilitySensitivity "0.92"^^xsd:decimal .
+
+sample:QingdaoMedTechCreditProfile a gesli:CustomerCreditProfile ;
+    gesli:overdueDays 0 ;
+    gesli:creditTermDays 15 .
+
+sample:QingdaoMedTechServiceProfile a gesli:CustomerServiceProfile ;
+    gesli:notificationRequirement "起飞和到达即时通知" ;
+    gesli:documentRequirement "温控与医疗器械文件零差错" ;
+    gesli:exceptionResponseRequirement "30分钟内响应" .
+
+# Qingdao to Hamburg market context and three current offers.
+sample:MarketBenchmark_QD_HAM_202607 a gesli:MarketPriceBenchmark ;
+    rdfs:label "青岛-汉堡2026年7月市场基准"@zh ;
+    gesli:marketBenchmarkAmount "42500"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "汉堡港" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-15"^^xsd:date .
+
+sample:Strategy_QD_HAM_MarketShare a gesli:MarketShareStrategy ;
+    rdfs:label "青岛-汉堡市场占有策略"@zh ;
+    gesli:strategyCode "STR-QD-HAM-SHARE-202607" ;
+    gesli:strategyName "青岛-汉堡市场占有策略" ;
+    gesli:strategyObjective "以稳定中欧航线报价扩大制造业客户覆盖" ;
+    gesli:strategyEnabled true ;
+    gesli:strategyStatus "已启用" ;
+    gesli:strategyAdjustmentRate "-0.025"^^xsd:decimal ;
+    gesli:strategyScopeOrigin "青岛港" ;
+    gesli:strategyScopeDestination "汉堡港" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-15"^^xsd:date .
+
+sample:Schedule_Cosco_QD_HAM_20260730 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-COSCO-QD-HAM-20260730" ;
+    gesli:scheduleOfProduct sample:Product_Cosco_QD_Hamburg_TransshipFcl ;
+    gesli:documentCutoffTime "2026-07-28T15:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-30T20:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-31T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "32"^^xsd:decimal .
+
+sample:Quote_Cosco_QD_HAM_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-COSCO-HAM-20260720" ;
+    gesli:quotedBySupplier sample:CoscoQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_CoscoBranch_Cosco_QD_Hamburg ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Cosco_QD_HAM_20260730 ;
+    gesli:supplierCostAmount "35000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-05"^^xsd:date .
+
+sample:HapagLloyd a gesli:ServiceBrand ;
+    rdfs:label "赫伯罗特"@zh ;
+    gesli:brandCode "HAPAG" ;
+    gesli:brandName "赫伯罗特" .
+
+sample:HapagQingdaoBranch a gesli:SupplierLocalUnit, gesli:Carrier ;
+    rdfs:label "赫伯罗特青岛分支机构"@zh ;
+    gesli:platformSupplierType "船公司" ;
+    gesli:servicePort "青岛港" ;
+    gesli:isActivePartner true ;
+    gesli:authorizedForServiceBrand sample:HapagLloyd ;
+    gesli:hasProductOffering sample:Offering_Hapag_QD_HAM_Direct .
+
+sample:HapagQdHamPerformance a gesli:ProductPerformanceProfile ;
+    gesli:etdAtdOnTimeRate "0.94"^^xsd:decimal ;
+    gesli:etaAtaOnTimeRate "0.91"^^xsd:decimal .
+
+sample:Product_Hapag_QD_HAM_Direct a gesli:ContainerOceanProduct ;
+    gesli:productCode "HAPAG-QD-HAM-DIRECT" ;
+    gesli:productName "青岛-汉堡直航优先舱40HQ" ;
+    gesli:hasServiceBrand sample:HapagLloyd ;
+    gesli:hasProductPerformanceProfile sample:HapagQdHamPerformance ;
+    gesli:transportMode "海运" ;
+    gesli:serviceForm "包柜" ;
+    gesli:routeType "直航" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "汉堡港" ;
+    gesli:estimatedTransitDays "27"^^xsd:decimal .
+
+sample:Offering_Hapag_QD_HAM_Direct a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-HAPAG-QD-HAM" ;
+    gesli:offeredBySupplier sample:HapagQingdaoBranch ;
+    gesli:offeringProduct sample:Product_Hapag_QD_HAM_Direct ;
+    gesli:offeringChannel "分公司直营" ;
+    gesli:agencyLevel "产品品牌本地分支机构" ;
+    gesli:capacityCommitment "每周保证10TEU" ;
+    gesli:capacityAvailable true ;
+    gesli:bookingSuccessRate "0.96"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.95"^^xsd:decimal .
+
+sample:Schedule_Hapag_QD_HAM_20260801 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-HAPAG-QD-HAM-20260801" ;
+    gesli:scheduleOfProduct sample:Product_Hapag_QD_HAM_Direct ;
+    gesli:documentCutoffTime "2026-07-30T12:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-08-01T18:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-28T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "27"^^xsd:decimal .
+
+sample:Quote_Hapag_QD_HAM_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-HAPAG-HAM-20260720" ;
+    gesli:quotedBySupplier sample:HapagQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_Hapag_QD_HAM_Direct ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Hapag_QD_HAM_20260801 ;
+    gesli:supplierCostAmount "38500"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-05"^^xsd:date .
+
+sample:EuropeBookingAgentB a gesli:ShippingAgencyCompany ;
+    rdfs:label "青岛欧洲航线代理B"@zh ;
+    gesli:platformSupplierType "船代公司" ;
+    gesli:servicePort "青岛港" ;
+    gesli:isActivePartner true ;
+    gesli:hasProductOffering sample:Offering_EuropeAgent_QD_HAM_Economy .
+
+sample:Product_EuropeAgent_QD_HAM_Economy a gesli:ContainerOceanProduct ;
+    gesli:productCode "EUROPEAGENT-QD-HAM-ECONOMY" ;
+    gesli:productName "青岛-汉堡中转经济舱40HQ" ;
+    gesli:transportMode "海运" ;
+    gesli:serviceForm "包柜" ;
+    gesli:routeType "中转" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "汉堡港" ;
+    gesli:estimatedTransitDays "34"^^xsd:decimal .
+
+sample:Offering_EuropeAgent_QD_HAM_Economy a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-EUROPEAGENT-QD-HAM" ;
+    gesli:offeredBySupplier sample:EuropeBookingAgentB ;
+    gesli:offeringProduct sample:Product_EuropeAgent_QD_HAM_Economy ;
+    gesli:offeringChannel "订舱代理" ;
+    gesli:agencyLevel "一级代理" ;
+    gesli:capacityCommitment "每周滚动确认8TEU" ;
+    gesli:capacityAvailable true ;
+    gesli:bookingSuccessRate "0.90"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.88"^^xsd:decimal .
+
+sample:Schedule_EuropeAgent_QD_HAM_20260802 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-EUROPEAGENT-QD-HAM-20260802" ;
+    gesli:scheduleOfProduct sample:Product_EuropeAgent_QD_HAM_Economy ;
+    gesli:documentCutoffTime "2026-07-31T12:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-08-02T20:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-09-05T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "34"^^xsd:decimal .
+
+sample:Quote_EuropeAgent_QD_HAM_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-EUROPEAGENT-HAM-20260720" ;
+    gesli:quotedBySupplier sample:EuropeBookingAgentB ;
+    gesli:quotedForOffering sample:Offering_EuropeAgent_QD_HAM_Economy ;
+    gesli:quoteAppliesToSchedule sample:Schedule_EuropeAgent_QD_HAM_20260802 ;
+    gesli:supplierCostAmount "32800"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-05"^^xsd:date .
+
+# Dangerous-goods-only products use the existing QD-LA market context.
+sample:Product_Maersk_QD_LA_DG a gesli:ContainerOceanProduct ;
+    gesli:productCode "MSK-QD-LA-DG" ;
+    gesli:productName "青岛-洛杉矶危险品直航40HQ" ;
+    gesli:hasServiceBrand sample:Maersk ;
+    gesli:hasProductPerformanceProfile sample:MaerskQingdaoPunctualityProfile ;
+    gesli:transportMode "海运" ;
+    gesli:serviceForm "危险品包柜" ;
+    gesli:routeType "直航" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "洛杉矶港" ;
+    gesli:estimatedTransitDays "16"^^xsd:decimal .
+
+sample:Offering_Maersk_QD_LA_DG a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-MSK-QD-LA-DG" ;
+    gesli:offeredBySupplier sample:MaerskQingdaoBranch ;
+    gesli:offeringProduct sample:Product_Maersk_QD_LA_DG ;
+    gesli:offeringChannel "分公司直营" ;
+    gesli:agencyLevel "产品品牌本地分支机构" ;
+    gesli:capacityCommitment "每周危险品舱位6TEU" ;
+    gesli:capacityAvailable true ;
+    gesli:supportsDangerousGoods true ;
+    gesli:dangerousGoodsOnly true ;
+    gesli:bookingSuccessRate "0.95"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.93"^^xsd:decimal .
+
+sample:Offering_AgentA_QD_LA_DG a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-AGENTA-QD-LA-DG" ;
+    gesli:offeredBySupplier sample:QingdaoOceanBookingAgentA ;
+    gesli:offeringProduct sample:Product_Maersk_QD_LA_DG ;
+    gesli:offeringChannel "危险品订舱代理" ;
+    gesli:agencyLevel "一级代理" ;
+    gesli:capacityCommitment "按危险品审核结果确认" ;
+    gesli:capacityAvailable true ;
+    gesli:supportsDangerousGoods true ;
+    gesli:dangerousGoodsOnly true ;
+    gesli:bookingSuccessRate "0.88"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.86"^^xsd:decimal .
+
+sample:Schedule_Maersk_QD_LA_DG_20260729 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-MSK-QD-LA-DG-20260729" ;
+    gesli:scheduleOfProduct sample:Product_Maersk_QD_LA_DG ;
+    gesli:documentCutoffTime "2026-07-26T12:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-29T20:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-14T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "16"^^xsd:decimal .
+
+sample:Quote_Maersk_QD_LA_DG_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-MSK-DG-20260720" ;
+    gesli:quotedBySupplier sample:MaerskQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_Maersk_QD_LA_DG ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Maersk_QD_LA_DG_20260729 ;
+    gesli:supplierCostAmount "52000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+sample:Quote_AgentA_QD_LA_DG_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-AGENTA-DG-20260720" ;
+    gesli:quotedBySupplier sample:QingdaoOceanBookingAgentA ;
+    gesli:quotedForOffering sample:Offering_AgentA_QD_LA_DG ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Maersk_QD_LA_DG_20260729 ;
+    gesli:supplierCostAmount "50000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+sample:Product_Oocl_QD_LA_DG a gesli:ContainerOceanProduct ;
+    gesli:productCode "OOCL-QD-LA-DG" ;
+    gesli:productName "青岛-洛杉矶危险品快线40HQ" ;
+    gesli:hasServiceBrand sample:Oocl ;
+    gesli:hasProductPerformanceProfile sample:OoclQdLaPerformance ;
+    gesli:transportMode "海运" ;
+    gesli:serviceForm "危险品包柜" ;
+    gesli:routeType "直航" ;
+    gesli:originLocation "青岛港" ;
+    gesli:destinationLocation "洛杉矶港" ;
+    gesli:estimatedTransitDays "14"^^xsd:decimal .
+
+sample:Offering_Oocl_QD_LA_DG a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-OOCL-QD-LA-DG" ;
+    gesli:offeredBySupplier sample:OoclQingdaoBranch ;
+    gesli:offeringProduct sample:Product_Oocl_QD_LA_DG ;
+    gesli:offeringChannel "分公司直营" ;
+    gesli:agencyLevel "产品品牌本地分支机构" ;
+    gesli:capacityCommitment "每周危险品舱位4TEU" ;
+    gesli:capacityAvailable true ;
+    gesli:supportsDangerousGoods true ;
+    gesli:dangerousGoodsOnly true ;
+    gesli:bookingSuccessRate "0.97"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.96"^^xsd:decimal .
+
+sample:Schedule_Oocl_QD_LA_DG_20260730 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-OOCL-QD-LA-DG-20260730" ;
+    gesli:scheduleOfProduct sample:Product_Oocl_QD_LA_DG ;
+    gesli:documentCutoffTime "2026-07-27T10:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-30T18:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-13T08:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "14"^^xsd:decimal .
+
+sample:Quote_Oocl_QD_LA_DG_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-OOCL-DG-20260720" ;
+    gesli:quotedBySupplier sample:OoclQingdaoBranch ;
+    gesli:quotedForOffering sample:Offering_Oocl_QD_LA_DG ;
+    gesli:quoteAppliesToSchedule sample:Schedule_Oocl_QD_LA_DG_20260730 ;
+    gesli:supplierCostAmount "53500"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+
+# Qingdao Jiaodong to Frankfurt air-freight market.
+sample:MarketBenchmark_QD_FRA_AIR_202607 a gesli:MarketPriceBenchmark ;
+    gesli:marketBenchmarkAmount "68000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:originLocation "青岛胶东机场" ;
+    gesli:destinationLocation "法兰克福机场" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-15"^^xsd:date .
+
+sample:Strategy_QD_FRA_Time a gesli:MarketShareStrategy ;
+    gesli:strategyCode "STR-QD-FRA-TIME-202607" ;
+    gesli:strategyName "中欧空运时效产品策略" ;
+    gesli:strategyObjective "提高医疗器械和高价值货物的紧急运输覆盖" ;
+    gesli:strategyEnabled true ;
+    gesli:strategyStatus "已启用" ;
+    gesli:strategyAdjustmentRate "-0.02"^^xsd:decimal ;
+    gesli:strategyScopeOrigin "青岛胶东机场" ;
+    gesli:strategyScopeDestination "法兰克福机场" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-08-15"^^xsd:date .
+
+sample:LufthansaCargo a gesli:ServiceBrand ;
+    gesli:brandCode "LH-CARGO" ;
+    gesli:brandName "汉莎货运" .
+sample:KoreanAirCargo a gesli:ServiceBrand ;
+    gesli:brandCode "KE-CARGO" ;
+    gesli:brandName "大韩航空货运" .
+sample:CathayCargo a gesli:ServiceBrand ;
+    gesli:brandCode "CX-CARGO" ;
+    gesli:brandName "国泰货运" .
+
+sample:LufthansaCargoAgentQd a gesli:AirCargoAgent ;
+    rdfs:label "汉莎货运青岛代理"@zh ;
+    gesli:platformSupplierType "航空代理" ;
+    gesli:isActivePartner true .
+sample:KoreanAirQd a gesli:AirlineCompany ;
+    rdfs:label "大韩航空青岛货运部"@zh ;
+    gesli:platformSupplierType "航空公司" ;
+    gesli:isActivePartner true .
+sample:CathayCargoAgentQd a gesli:AirCargoAgent ;
+    rdfs:label "国泰货运青岛代理"@zh ;
+    gesli:platformSupplierType "航空代理" ;
+    gesli:isActivePartner true .
+
+sample:Product_LH_QD_FRA_Express a gesli:AirFreightProduct ;
+    gesli:productCode "LH-QD-FRA-EXPRESS" ;
+    gesli:productName "青岛-法兰克福空运直达加急" ;
+    gesli:hasServiceBrand sample:LufthansaCargo ;
+    gesli:transportMode "空运" ;
+    gesli:serviceForm "机场到机场" ;
+    gesli:routeType "直达" ;
+    gesli:originLocation "青岛胶东机场" ;
+    gesli:destinationLocation "法兰克福机场" ;
+    gesli:estimatedTransitDays "3"^^xsd:decimal .
+
+sample:Product_KE_QD_FRA_Balanced a gesli:AirFreightProduct ;
+    gesli:productCode "KE-QD-FRA-BALANCED" ;
+    gesli:productName "青岛-法兰克福首尔中转空运" ;
+    gesli:hasServiceBrand sample:KoreanAirCargo ;
+    gesli:transportMode "空运" ;
+    gesli:serviceForm "机场到机场" ;
+    gesli:routeType "中转" ;
+    gesli:originLocation "青岛胶东机场" ;
+    gesli:destinationLocation "法兰克福机场" ;
+    gesli:estimatedTransitDays "4"^^xsd:decimal .
+
+sample:Product_CX_QD_FRA_Economy a gesli:AirFreightProduct ;
+    gesli:productCode "CX-QD-FRA-ECONOMY" ;
+    gesli:productName "青岛-法兰克福香港中转经济空运" ;
+    gesli:hasServiceBrand sample:CathayCargo ;
+    gesli:transportMode "空运" ;
+    gesli:serviceForm "机场到机场" ;
+    gesli:routeType "中转" ;
+    gesli:originLocation "青岛胶东机场" ;
+    gesli:destinationLocation "法兰克福机场" ;
+    gesli:estimatedTransitDays "5"^^xsd:decimal .
+
+sample:Offering_LH_QD_FRA a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-LH-QD-FRA" ;
+    gesli:offeredBySupplier sample:LufthansaCargoAgentQd ;
+    gesli:offeringProduct sample:Product_LH_QD_FRA_Express ;
+    gesli:offeringChannel "航空代理" ;
+    gesli:agencyLevel "核心代理" ;
+    gesli:capacityCommitment "每周500公斤加急舱位" ;
+    gesli:capacityAvailable true ;
+    gesli:bookingSuccessRate "0.97"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.96"^^xsd:decimal .
+
+sample:Offering_KE_QD_FRA a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-KE-QD-FRA" ;
+    gesli:offeredBySupplier sample:KoreanAirQd ;
+    gesli:offeringProduct sample:Product_KE_QD_FRA_Balanced ;
+    gesli:offeringChannel "航空公司直营" ;
+    gesli:agencyLevel "航空公司本地货运部" ;
+    gesli:capacityCommitment "每周800公斤固定舱位" ;
+    gesli:capacityAvailable true ;
+    gesli:bookingSuccessRate "0.94"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.93"^^xsd:decimal .
+
+sample:Offering_CX_QD_FRA a gesli:SupplierProductOffering ;
+    gesli:offeringCode "OFF-CX-QD-FRA" ;
+    gesli:offeredBySupplier sample:CathayCargoAgentQd ;
+    gesli:offeringProduct sample:Product_CX_QD_FRA_Economy ;
+    gesli:offeringChannel "航空代理" ;
+    gesli:agencyLevel "一级代理" ;
+    gesli:capacityCommitment "每周滚动确认1000公斤" ;
+    gesli:capacityAvailable true ;
+    gesli:bookingSuccessRate "0.90"^^xsd:decimal ;
+    gesli:spaceReleaseSuccessRate "0.89"^^xsd:decimal .
+
+sample:Schedule_LH_QD_FRA_20260728 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-LH-QD-FRA-20260728" ;
+    gesli:scheduleOfProduct sample:Product_LH_QD_FRA_Express ;
+    gesli:documentCutoffTime "2026-07-27T10:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-28T14:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-07-31T08:00:00+02:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "3"^^xsd:decimal .
+sample:Schedule_KE_QD_FRA_20260728 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-KE-QD-FRA-20260728" ;
+    gesli:scheduleOfProduct sample:Product_KE_QD_FRA_Balanced ;
+    gesli:documentCutoffTime "2026-07-27T09:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-28T11:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-01T08:00:00+02:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "4"^^xsd:decimal .
+sample:Schedule_CX_QD_FRA_20260729 a gesli:ScheduledServiceInstance ;
+    gesli:scheduleCode "SCH-CX-QD-FRA-20260729" ;
+    gesli:scheduleOfProduct sample:Product_CX_QD_FRA_Economy ;
+    gesli:documentCutoffTime "2026-07-28T09:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedDepartureTime "2026-07-29T12:00:00+08:00"^^xsd:dateTime ;
+    gesli:estimatedArrivalTime "2026-08-03T08:00:00+02:00"^^xsd:dateTime ;
+    gesli:estimatedTransitDays "5"^^xsd:decimal .
+
+sample:Quote_LH_QD_FRA_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-LH-QD-FRA-20260720" ;
+    gesli:quotedBySupplier sample:LufthansaCargoAgentQd ;
+    gesli:quotedForOffering sample:Offering_LH_QD_FRA ;
+    gesli:quoteAppliesToSchedule sample:Schedule_LH_QD_FRA_20260728 ;
+    gesli:supplierCostAmount "52000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+sample:Quote_KE_QD_FRA_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-KE-QD-FRA-20260720" ;
+    gesli:quotedBySupplier sample:KoreanAirQd ;
+    gesli:quotedForOffering sample:Offering_KE_QD_FRA ;
+    gesli:quoteAppliesToSchedule sample:Schedule_KE_QD_FRA_20260728 ;
+    gesli:supplierCostAmount "49000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .
+sample:Quote_CX_QD_FRA_20260720 a gesli:SupplierQuote ;
+    gesli:quoteNo "SQ-CX-QD-FRA-20260720" ;
+    gesli:quotedBySupplier sample:CathayCargoAgentQd ;
+    gesli:quotedForOffering sample:Offering_CX_QD_FRA ;
+    gesli:quoteAppliesToSchedule sample:Schedule_CX_QD_FRA_20260729 ;
+    gesli:supplierCostAmount "46000"^^xsd:decimal ;
+    gesli:currency "CNY" ;
+    gesli:validFrom "2026-07-20"^^xsd:date ;
+    gesli:validTo "2026-07-31"^^xsd:date .

+ 55 - 0
ontology/ai-market-demo-extension.ttl

@@ -0,0 +1,55 @@
+@prefix gesli: <https://gesli.example/ontology#> .
+@prefix owl: <http://www.w3.org/2002/07/owl#> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+
+gesli:AIMarketDemoExtension a owl:Ontology ;
+    rdfs:label "格士立AI市场决策扩展本体"@zh ;
+    rdfs:comment "在不改变全球运输核心模型的前提下,记录询价约束、候选方案评分和可解释推荐过程。"@zh .
+
+gesli:RecommendationRun a owl:Class ;
+    rdfs:subClassOf gesli:BusinessObject ;
+    rdfs:label "AI市场推荐任务"@zh .
+
+gesli:CandidateEvaluation a owl:Class ;
+    rdfs:subClassOf gesli:BusinessObject ;
+    rdfs:label "候选方案评估"@zh .
+
+gesli:generatedForTransaction a owl:ObjectProperty ; rdfs:label "推荐任务对应询价/交易"@zh .
+gesli:hasCandidateEvaluation a owl:ObjectProperty ; rdfs:label "拥有候选方案评估"@zh .
+gesli:evaluatesSolution a owl:ObjectProperty ; rdfs:label "评估运输解决方案"@zh .
+gesli:evaluatedQuote a owl:ObjectProperty ; rdfs:label "评估供应商报价"@zh .
+gesli:usesMarketStrategy a owl:ObjectProperty ; rdfs:label "采用市场策略"@zh .
+gesli:recommendedEvaluation a owl:ObjectProperty ; rdfs:label "推荐的候选评估"@zh .
+
+gesli:requestedDepartureDate a owl:DatatypeProperty ; rdfs:range xsd:date ; rdfs:label "要求起运日期"@zh .
+gesli:latestArrivalDate a owl:DatatypeProperty ; rdfs:range xsd:date ; rdfs:label "最晚到达日期"@zh .
+gesli:targetAmount a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "客户目标金额"@zh .
+gesli:maxTransitDays a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "最大可接受运输天数"@zh .
+gesli:preferredRouteType a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "偏好路由类型"@zh .
+gesli:requiresDangerousGoodsService a owl:DatatypeProperty ; rdfs:range xsd:boolean ; rdfs:label "是否需要危险品服务"@zh .
+
+gesli:capacityAvailable a owl:DatatypeProperty ; rdfs:range xsd:boolean ; rdfs:label "运力是否可用"@zh .
+gesli:supportsDangerousGoods a owl:DatatypeProperty ; rdfs:range xsd:boolean ; rdfs:label "是否支持危险品"@zh .
+gesli:dangerousGoodsOnly a owl:DatatypeProperty ; rdfs:range xsd:boolean ; rdfs:label "是否为危险品专用产品"@zh .
+gesli:strategyAdjustmentRate a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "策略价格调整率"@zh .
+gesli:strategyScopeOrigin a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "策略适用起运地"@zh .
+gesli:strategyScopeDestination a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "策略适用目的地"@zh .
+
+gesli:customerPriceSensitivity a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "客户价格敏感度"@zh .
+gesli:customerTimeSensitivity a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "客户时效敏感度"@zh .
+gesli:customerReliabilitySensitivity a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "客户可靠性敏感度"@zh .
+
+gesli:isFeasible a owl:DatatypeProperty ; rdfs:range xsd:boolean ; rdfs:label "候选是否可执行"@zh .
+gesli:rejectionReason a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "候选淘汰原因"@zh .
+gesli:recommendationRank a owl:DatatypeProperty ; rdfs:range xsd:integer ; rdfs:label "推荐排名"@zh .
+gesli:customerFitScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "客户匹配得分"@zh .
+gesli:priceScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "价格得分"@zh .
+gesli:transitScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "时效得分"@zh .
+gesli:supplierExecutionScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "供应商执行得分"@zh .
+gesli:productStrategyScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "产品策略得分"@zh .
+gesli:totalScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "综合得分"@zh .
+gesli:confidenceScore a owl:DatatypeProperty ; rdfs:range xsd:decimal ; rdfs:label "推荐置信度"@zh .
+gesli:recommendationReason a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "推荐理由"@zh .
+gesli:generatedAt a owl:DatatypeProperty ; rdfs:range xsd:dateTime ; rdfs:label "生成时间"@zh .
+gesli:costRiskStatus a owl:DatatypeProperty ; rdfs:range xsd:string ; rdfs:label "成本风险状态"@zh .

+ 66 - 0
queries/market-candidates.rq

@@ -0,0 +1,66 @@
+PREFIX gesli: <https://gesli.example/ontology#>
+PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
+
+SELECT ?quote ?quoteNo ?validFrom ?validTo ?currency ?supplierCostAmount
+       ?offering ?offeringCode ?offeringChannel ?agencyLevel ?capacityCommitment
+       ?capacityAvailable ?supportsDangerousGoods ?dangerousGoodsOnly ?bookingSuccessRate ?spaceReleaseSuccessRate
+       ?supplier ?supplierName ?supplierActive
+       ?product ?productCode ?productName ?brandName ?transportMode ?serviceForm ?routeType
+       ?originLocation ?destinationLocation ?estimatedTransitDays
+       ?schedule ?scheduleCode ?documentCutoffTime ?estimatedDepartureTime ?estimatedArrivalTime
+       ?etdAtdOnTimeRate ?etaAtaOnTimeRate
+WHERE {
+  ?quote a gesli:SupplierQuote ;
+         gesli:quoteNo ?quoteNo ;
+         gesli:quotedForOffering ?offering ;
+         gesli:supplierCostAmount ?supplierCostAmount ;
+         gesli:currency ?currency .
+
+  ?offering gesli:offeredBySupplier ?supplier ;
+            gesli:offeringProduct ?product .
+  ?supplier rdfs:label ?supplierName .
+  ?product gesli:productCode ?productCode ;
+           gesli:productName ?productName ;
+           gesli:transportMode ?transportMode .
+
+  OPTIONAL { ?quote gesli:validFrom ?validFrom . }
+  OPTIONAL { ?quote gesli:validTo ?validTo . }
+  OPTIONAL { ?offering gesli:offeringCode ?offeringCode . }
+  OPTIONAL { ?offering gesli:offeringChannel ?offeringChannel . }
+  OPTIONAL { ?offering gesli:agencyLevel ?agencyLevel . }
+  OPTIONAL { ?offering gesli:capacityCommitment ?capacityCommitment . }
+  OPTIONAL { ?offering gesli:capacityAvailable ?capacityAvailable . }
+  OPTIONAL { ?offering gesli:supportsDangerousGoods ?supportsDangerousGoods . }
+  OPTIONAL { ?offering gesli:dangerousGoodsOnly ?dangerousGoodsOnly . }
+  OPTIONAL { ?offering gesli:bookingSuccessRate ?bookingSuccessRate . }
+  OPTIONAL { ?offering gesli:spaceReleaseSuccessRate ?spaceReleaseSuccessRate . }
+  OPTIONAL { ?supplier gesli:isActivePartner ?directSupplierActive . }
+  OPTIONAL {
+    ?supplier gesli:hasBusinessPartnerBasicInfo ?supplierBasic .
+    ?supplierBasic gesli:isActivePartner ?basicSupplierActive .
+  }
+  BIND(COALESCE(?directSupplierActive, ?basicSupplierActive, true) AS ?supplierActive)
+
+  OPTIONAL { ?product gesli:serviceForm ?serviceForm . }
+  OPTIONAL { ?product gesli:routeType ?routeType . }
+  OPTIONAL { ?product gesli:originLocation ?originLocation . }
+  OPTIONAL { ?product gesli:destinationLocation ?destinationLocation . }
+  OPTIONAL { ?product gesli:estimatedTransitDays ?estimatedTransitDays . }
+  OPTIONAL {
+    ?product gesli:hasServiceBrand ?brand .
+    ?brand gesli:brandName ?brandName .
+  }
+  OPTIONAL {
+    ?product gesli:hasProductPerformanceProfile ?performance .
+    OPTIONAL { ?performance gesli:etdAtdOnTimeRate ?etdAtdOnTimeRate . }
+    OPTIONAL { ?performance gesli:etaAtaOnTimeRate ?etaAtaOnTimeRate . }
+  }
+  OPTIONAL {
+    ?quote gesli:quoteAppliesToSchedule ?schedule .
+    OPTIONAL { ?schedule gesli:scheduleCode ?scheduleCode . }
+    OPTIONAL { ?schedule gesli:documentCutoffTime ?documentCutoffTime . }
+    OPTIONAL { ?schedule gesli:estimatedDepartureTime ?estimatedDepartureTime . }
+    OPTIONAL { ?schedule gesli:estimatedArrivalTime ?estimatedArrivalTime . }
+  }
+}
+ORDER BY ?supplierCostAmount ?supplierName

+ 30 - 0
queries/market-context.rq

@@ -0,0 +1,30 @@
+PREFIX gesli: <https://gesli.example/ontology#>
+PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
+
+SELECT ?kind ?resource ?amount ?currency ?validFrom ?validTo ?origin ?destination
+       ?strategyName ?strategyObjective ?strategyAdjustmentRate
+WHERE {
+  {
+    ?resource a gesli:MarketPriceBenchmark ;
+              gesli:marketBenchmarkAmount ?amount ;
+              gesli:currency ?currency .
+    OPTIONAL { ?resource gesli:originLocation ?origin . }
+    OPTIONAL { ?resource gesli:destinationLocation ?destination . }
+    OPTIONAL { ?resource gesli:validFrom ?validFrom . }
+    OPTIONAL { ?resource gesli:validTo ?validTo . }
+    BIND("benchmark" AS ?kind)
+  }
+  UNION
+  {
+    ?resource a/rdfs:subClassOf* gesli:ProductStrategy ;
+              gesli:strategyName ?strategyName ;
+              gesli:strategyObjective ?strategyObjective ;
+              gesli:strategyEnabled true ;
+              gesli:strategyAdjustmentRate ?strategyAdjustmentRate .
+    OPTIONAL { ?resource gesli:strategyScopeOrigin ?origin . }
+    OPTIONAL { ?resource gesli:strategyScopeDestination ?destination . }
+    OPTIONAL { ?resource gesli:validFrom ?validFrom . }
+    OPTIONAL { ?resource gesli:validTo ?validTo . }
+    BIND("strategy" AS ?kind)
+  }
+}

+ 32 - 0
queries/market-customers.rq

@@ -0,0 +1,32 @@
+PREFIX gesli: <https://gesli.example/ontology#>
+PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
+
+SELECT ?customer ?customerName ?industry ?routeName ?repeatPurchaseCount ?quoteWinRate
+       ?overdueDays ?notificationRequirement ?documentRequirement ?exceptionResponseRequirement
+       ?customerPriceSensitivity ?customerTimeSensitivity ?customerReliabilitySensitivity
+WHERE {
+  ?customer a/rdfs:subClassOf* gesli:Customer ;
+            rdfs:label ?customerName .
+  OPTIONAL {
+    ?customer gesli:hasTradeProfile ?tradeProfile .
+    OPTIONAL { ?tradeProfile gesli:industry ?industry . }
+    OPTIONAL { ?tradeProfile gesli:routeName ?routeName . }
+    OPTIONAL { ?tradeProfile gesli:repeatPurchaseCount ?repeatPurchaseCount . }
+    OPTIONAL { ?tradeProfile gesli:quoteWinRate ?quoteWinRate . }
+    OPTIONAL { ?tradeProfile gesli:customerPriceSensitivity ?customerPriceSensitivity . }
+    OPTIONAL { ?tradeProfile gesli:customerTimeSensitivity ?customerTimeSensitivity . }
+    OPTIONAL { ?tradeProfile gesli:customerReliabilitySensitivity ?customerReliabilitySensitivity . }
+  }
+  OPTIONAL {
+    ?customer gesli:hasCreditProfile ?creditProfile .
+    OPTIONAL { ?creditProfile gesli:overdueDays ?overdueDays . }
+  }
+  OPTIONAL {
+    ?customer gesli:hasServiceProfile ?serviceProfile .
+    OPTIONAL { ?serviceProfile gesli:notificationRequirement ?notificationRequirement . }
+    OPTIONAL { ?serviceProfile gesli:documentRequirement ?documentRequirement . }
+    OPTIONAL { ?serviceProfile gesli:exceptionResponseRequirement ?exceptionResponseRequirement . }
+  }
+}
+ORDER BY ?customerName
+

+ 1 - 0
queries/product-performance.rq

@@ -1,4 +1,5 @@
 PREFIX gesli: <https://gesli.example/ontology#>
+PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
 
 SELECT ?brandName ?productName ?routeType ?originLocation ?destinationLocation
        ?etdAtdOnTimeRate ?etaAtaOnTimeRate ?scheduleDelayRate

+ 6 - 0
tests/basic_check.py

@@ -8,7 +8,10 @@ def main():
         "README.md",
         "docker-compose.yml",
         "ontology/global-shipment.ttl",
+        "ontology/ai-market-demo-extension.ttl",
         "data/qingdao-export-sample.ttl",
+        "data/ai-market-demo.ttl",
+        "data/ai-market-scenarios.ttl",
         "queries/order-status.rq",
         "queries/risk-orders.rq",
         "queries/supplier-product-quotes.rq",
@@ -16,6 +19,9 @@ def main():
         "queries/ai-market-recommended-prices.rq",
         "queries/supplier-share-governance.rq",
         "app/main.py",
+        "app/repository.py",
+        "app/market/service.py",
+        "app/mock_data.py",
     ]
     for path in required:
         if not (ROOT / path).exists():

+ 122 - 0
tests/test_market_engine.py

@@ -0,0 +1,122 @@
+import json
+from pathlib import Path
+import unittest
+
+from app.market import MarketService
+from app.market.models import InquiryRequest
+from app.repository import RDFRepository
+
+
+ROOT = Path(__file__).resolve().parents[1]
+HAIER = "https://gesli.example/sample#Haier"
+HISENSE = "https://gesli.example/sample#Hisense"
+
+
+class MarketEngineTest(unittest.TestCase):
+    def setUp(self):
+        self.service = MarketService(ROOT, RDFRepository(ROOT))
+
+    def test_default_inquiry_builds_three_ranked_solutions(self):
+        result = self.service.recommend(InquiryRequest(customer=HAIER))
+
+        self.assertEqual(
+            result["pipeline"],
+            {
+                "discovered": 9,
+                "rejected": 6,
+                "feasible": 3,
+                "generated_solutions": 3,
+            },
+        )
+        self.assertEqual(result["recommendation"]["solution_type"], "均衡方案")
+        self.assertEqual(result["recommendation"]["supplier_name"], "马士基青岛分支机构")
+        self.assertEqual(
+            {candidate["solution_type"] for candidate in result["candidates"]},
+            {"经济方案", "均衡方案", "时效方案"},
+        )
+
+    def test_form_only_exposes_routes_with_market_context(self):
+        options = self.service.form_options()
+
+        self.assertEqual(options["origins"], ["青岛港", "青岛胶东机场"])
+        self.assertEqual(options["destinations"], ["汉堡港", "法兰克福机场", "洛杉矶港"])
+        self.assertEqual(options["route_types"], ["中转", "直航", "直达"])
+        self.assertEqual(len(options["routes"]), 3)
+
+    def test_customer_profile_changes_recommendation(self):
+        haier = self.service.recommend(InquiryRequest(customer=HAIER))
+        hisense = self.service.recommend(InquiryRequest(customer=HISENSE))
+
+        self.assertEqual(haier["recommendation"]["solution_type"], "均衡方案")
+        self.assertEqual(hisense["recommendation"]["solution_type"], "经济方案")
+        self.assertNotEqual(
+            haier["recommendation"]["final_quote_amount"],
+            hisense["recommendation"]["final_quote_amount"],
+        )
+
+    def test_expired_quotes_are_rejected_with_reasons(self):
+        result = self.service.recommend(InquiryRequest(customer=HAIER))
+        rejected = {item["quote_no"]: item for item in result["rejected_candidates"]}
+
+        self.assertIn("SQ-EGL-20260710-EXPIRED", rejected)
+        self.assertTrue(
+            any("报价已于2026-07-18失效" in reason for reason in rejected["SQ-EGL-20260710-EXPIRED"]["reasons"])
+        )
+
+    def test_dangerous_goods_uses_only_specialized_candidates(self):
+        inquiry = InquiryRequest(
+            customer="https://gesli.example/sample#Sailun",
+            dangerous_goods=True,
+        )
+        result = self.service.recommend(inquiry)
+
+        self.assertEqual(result["pipeline"]["feasible"], 3)
+        self.assertTrue(
+            all("危险品" in candidate["product_name"] for candidate in result["candidates"])
+        )
+
+    def test_hamburg_route_builds_three_solutions(self):
+        result = self.service.recommend(InquiryRequest(
+            customer="https://gesli.example/sample#Midea",
+            destination="汉堡港",
+            requested_departure_date="2026-07-28",
+            max_transit_days=36,
+            target_amount=42500,
+        ))
+
+        self.assertEqual(result["pipeline"]["feasible"], 3)
+        self.assertEqual(result["pipeline"]["rejected"], 1)
+
+    def test_time_sensitive_air_customer_gets_express_solution(self):
+        result = self.service.recommend(InquiryRequest(
+            customer="https://gesli.example/sample#QingdaoMedTech",
+            origin="青岛胶东机场",
+            destination="法兰克福机场",
+            requested_departure_date="2026-07-27",
+            max_transit_days=6,
+            target_amount=68000,
+            preferred_route_type="直达",
+        ))
+
+        self.assertEqual(result["recommendation"]["solution_type"], "时效方案")
+        self.assertEqual(result["recommendation"]["supplier_name"], "汉莎货运青岛代理")
+
+    def test_sales_response_does_not_expose_supplier_cost(self):
+        result = self.service.recommend(InquiryRequest(customer=HAIER))
+        serialized = json.dumps(result, ensure_ascii=False)
+
+        self.assertNotIn("supplier_cost", serialized)
+        self.assertNotIn("supplierCostAmount", serialized)
+        self.assertIn("cost_risk_status", serialized)
+
+    def test_decision_audit_is_recorded_as_rdf_assertions(self):
+        result = self.service.recommend(InquiryRequest(customer=HAIER))
+        audit = self.service.get_audit(result["run_id"])
+
+        self.assertIsNotNone(audit)
+        self.assertEqual(audit["candidate_count"], 3)
+        self.assertGreaterEqual(audit["rdf_assertions"], 9)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 59 - 0
tests/test_mock_data.py

@@ -0,0 +1,59 @@
+from pathlib import Path
+import unittest
+
+from app.market import MarketService
+from app.market.models import InquiryRequest
+from app.mock_data import LocalMockDataService, MockBatchRequest
+from app.repository import RDFRepository
+
+
+ROOT = Path(__file__).resolve().parents[1]
+HAIER = "https://gesli.example/sample#Haier"
+
+
+class MockDataTest(unittest.TestCase):
+    def setUp(self):
+        self.repository = RDFRepository(ROOT)
+        self.market = MarketService(ROOT, self.repository)
+        self.mock_data = LocalMockDataService(ROOT, self.repository, self.market)
+
+    def test_catalog_exposes_local_domain_data(self):
+        catalog = self.mock_data.catalog()
+
+        self.assertEqual(catalog["summary"]["in_memory_triple_count"], 0)
+        self.assertGreaterEqual(catalog["summary"]["customer_count"], 7)
+        self.assertGreaterEqual(catalog["summary"]["quote_count"], 18)
+        self.assertGreaterEqual(catalog["summary"]["strategy_count"], 3)
+
+    def test_generated_batch_immediately_joins_recommendation_pool(self):
+        before = self.market.recommend(InquiryRequest(customer=HAIER))
+        result = self.mock_data.generate(MockBatchRequest(
+            origin="青岛港",
+            destination="洛杉矶港",
+            supplier_count=2,
+            customer_count=1,
+        ))
+        after = self.market.recommend(InquiryRequest(customer=HAIER))
+
+        self.assertGreater(result["added_triples"], 100)
+        self.assertEqual(result["catalog"]["summary"]["in_memory_triple_count"], result["added_triples"])
+        self.assertEqual(after["pipeline"]["discovered"], before["pipeline"]["discovered"] + 2)
+        self.assertEqual(result["catalog"]["summary"]["customer_count"], 8)
+
+    def test_reset_discards_only_runtime_mock_data(self):
+        baseline = self.repository.triple_count
+        self.mock_data.generate(MockBatchRequest(
+            origin="青岛港",
+            destination="汉堡港",
+            supplier_count=1,
+            customer_count=1,
+        ))
+        reset_catalog = self.mock_data.reset()
+
+        self.assertEqual(self.repository.triple_count, baseline)
+        self.assertEqual(reset_catalog["summary"]["in_memory_triple_count"], 0)
+        self.assertFalse(any(row["runtime_mock"] for row in reset_catalog["quotes"]))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 40 - 0
tests/test_repository.py

@@ -0,0 +1,40 @@
+from pathlib import Path
+import unittest
+
+from app.repository import RDFRepository
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class RepositoryTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.repository = RDFRepository(ROOT)
+
+    def test_loads_core_extension_and_demo_data(self):
+        self.assertGreater(self.repository.triple_count, 2700)
+        self.assertEqual(
+            self.repository.loaded_file_names,
+            [
+                "ontology/global-shipment.ttl",
+                "ontology/ai-market-demo-extension.ttl",
+                "data/ai-market-demo.ttl",
+                "data/ai-market-scenarios.ttl",
+                "data/qingdao-export-sample.ttl",
+            ],
+        )
+
+    def test_every_query_executes_locally(self):
+        for path in sorted((ROOT / "queries").glob("*.rq")):
+            with self.subTest(query=path.name):
+                result = self.repository.query_file(path)
+                self.assertIn("rows", result)
+
+    def test_legacy_order_query_still_returns_sample_orders(self):
+        result = self.repository.query_file(ROOT / "queries" / "order-status.rq")
+        self.assertEqual(len(result["rows"]), 2)
+
+
+if __name__ == "__main__":
+    unittest.main()