Воркшоп

Шаг 1. Начало

Переменные

initial_state

Field Type = JSON Code
Required = true

breaking_news

Field Type = Short text
Required = false

Шаг 2. Настройка агента обновления

Входящие переменные (Input Variables)

state → initial_state

PYTHON код агента
import random

def main(state: dict) -> dict:
    # ==========================================
    # 1. ПРОИЗВОДСТВО СЫРЬЯ (Заводы-поставщики)
    # ==========================================
    # Учалы (Базальтовая крошка)
    if state['factory_uchaly_basalt']['status'] != 'maintenance':
        prod = state['factory_uchaly_basalt']['current_output'] * random.uniform(0.9, 1.1)
        state['factory_uchaly_basalt']['ready_to_ship_stock'] += prod
        if random.random() < 0.05: state['factory_uchaly_basalt']['status'] = 'maintenance'
    else:
        if random.random() < 0.2: state['factory_uchaly_basalt']['status'] = 'normal'

    # Воскресенск (Стеклохолст)
    if state['factory_voskresensk_fiberglass']['status'] != 'maintenance':
        prod = state['factory_voskresensk_fiberglass']['current_output'] * random.uniform(0.9, 1.1)
        state['factory_voskresensk_fiberglass']['ready_to_ship_stock'] += prod
        if random.random() < 0.05: state['factory_voskresensk_fiberglass']['status'] = 'maintenance'
    else:
        if random.random() < 0.2: state['factory_voskresensk_fiberglass']['status'] = 'normal'

    # Рязань (Битум)
    if state['factory_ryazan_bitumen']['status'] != 'maintenance':
        prod = state['factory_ryazan_bitumen']['current_output'] * random.uniform(0.9, 1.1)
        state['factory_ryazan_bitumen']['ready_to_ship_stock'] += prod
        if random.random() < 0.05: state['factory_ryazan_bitumen']['status'] = 'maintenance'
    else:
        if random.random() < 0.2: state['factory_ryazan_bitumen']['status'] = 'normal'

    # ==========================================
    # 2. ШТАТНАЯ ЛОГИСТИКА (Автопополнение СКЛАДОВ)
    # ==========================================
    # Базальт: Учалы -> Склад Дзержинска
    if state['warehouse_dzerzhinsk']['current_stock'] < state['warehouse_dzerzhinsk']['max_capacity']:
        transfer = min(50, state['factory_uchaly_basalt']['ready_to_ship_stock'])
        state['factory_uchaly_basalt']['ready_to_ship_stock'] -= transfer
        state['warehouse_dzerzhinsk']['current_stock'] += transfer

    # Стеклохолст: Воскресенск -> Склад Рязани
    if state['warehouse_ryazan']['current_stock'] < state['warehouse_ryazan']['max_capacity']:
        transfer = min(30, state['factory_voskresensk_fiberglass']['ready_to_ship_stock'])
        state['factory_voskresensk_fiberglass']['ready_to_ship_stock'] -= transfer
        state['warehouse_ryazan']['current_stock'] += transfer

    # ==========================================
    # 3. ПРОИЗВОДСТВО И ПОТРЕБЛЕНИЕ (Завод Черепицы)
    # ==========================================
    shingles_factory = state['factory_ryazan_shingles']
    bom = shingles_factory['bom_per_ton']
    
    # Целевое производство (с рандомом)
    target_production = shingles_factory['daily_production_target'] * random.uniform(0.8, 1.0)
    
    # Проверяем, хватает ли сырья для производства (поиск "бутылочного горлышка" в реальном времени)
    can_produce = target_production
    for mat, ratio in bom.items():
        needed = target_production * ratio
        available = shingles_factory['raw_materials_on_site'][mat]
        if available < needed:
            can_produce = min(can_produce, available / ratio if ratio > 0 else 0)
            
    # Фактическое производство готовой черепицы
    actual_production = max(0, can_produce)
    shingles_factory['current_stock_shingles'] += actual_production
    
    # Списание сырья согласно BOM
    for mat, ratio in bom.items():
        consumed = actual_production * ratio
        shingles_factory['raw_materials_on_site'][mat] = max(0, shingles_factory['raw_materials_on_site'][mat] - consumed)

    # ==========================================
    # 4. ШТАТНАЯ ЛОГИСТИКА (Автопополнение ЗАВОДА)
    # ==========================================
    # Базальт: Склад Дзержинска -> Завод Рязань
    needed_basalt = (shingles_factory['daily_production_target'] * bom['basalt_granulate'] * 2) - shingles_factory['raw_materials_on_site']['basalt_granulate']
    if needed_basalt > 0 and state['warehouse_dzerzhinsk']['current_stock'] > 20:
        transfer = min(30, needed_basalt, state['warehouse_dzerzhinsk']['current_stock'])
        state['warehouse_dzerzhinsk']['current_stock'] -= transfer
        shingles_factory['raw_materials_on_site']['basalt_granulate'] += transfer

    # Стеклохолст: Склад Рязани -> Завод Рязань
    needed_fiberglass = (shingles_factory['daily_production_target'] * bom['fiberglass'] * 2) - shingles_factory['raw_materials_on_site']['fiberglass']
    if needed_fiberglass > 0 and state['warehouse_ryazan']['current_stock'] > 10:
        transfer = min(15, needed_fiberglass, state['warehouse_ryazan']['current_stock'])
        state['warehouse_ryazan']['current_stock'] -= transfer
        shingles_factory['raw_materials_on_site']['fiberglass'] += transfer

    # Битум: Завод Битума -> Завод Черепицы (напрямую, минуя склад)
    needed_bitumen = (shingles_factory['daily_production_target'] * bom['bitumen'] * 2) - shingles_factory['raw_materials_on_site']['bitumen']
    if needed_bitumen > 0 and state['factory_ryazan_bitumen']['ready_to_ship_stock'] > 10:
        transfer = min(20, needed_bitumen, state['factory_ryazan_bitumen']['ready_to_ship_stock'])
        state['factory_ryazan_bitumen']['ready_to_ship_stock'] -= transfer
        shingles_factory['raw_materials_on_site']['bitumen'] += transfer

    # ==========================================
    # 5. ГЕНЕРАТОР ХАОСА (Инциденты на реальных маршрутах)
    # ==========================================
    incidents = []
    if random.random() < 0.20: # 20% шанс инцидента за тик
        incident_type = random.choice(['traffic_jam', 'truck_breakdown', 'weather_delay', 'road_closure'])
        hours = random.randint(2, 10)
        location = random.choice([
            'Учалы-Дзержинск', 'Дзержинск-Рязань', 
            'Воскресенск-Рязань', 'Рязань_Склад-Рязань_Завод', 
            'Рязань_Битум-Рязань_Завод'
        ])
        incidents.append({"type": incident_type, "hours_delay": hours, "location": location})

    # ==========================================
    # 6. ДИНАМИКА АВТОПАРКА
    # ==========================================
    for truck in state['trucks']:
        if truck['status'] == 'on_route':
            truck['eta_hours'] = max(0, truck['eta_hours'] - 1)
            if truck['eta_hours'] == 0:
                truck['status'] = 'available'
        elif truck['status'] == 'available' and random.random() < 0.03:
            # 3% шанс поломки свободной машины
            truck['status'] = 'maintenance'
            truck['eta_hours'] = random.randint(2, 6)
        elif truck['status'] == 'maintenance':
            truck['eta_hours'] = max(0, truck['eta_hours'] - 1)
            if truck['eta_hours'] == 0:
                truck['status'] = 'available'

    state['incidents'] = incidents
    return {"updated_state": state}

Исходящие переменные (Output Variables)

updated_state

Type = Object

Шаг 3. Настройка агента планировщика

Входящие переменные (Input Variables)

state → updated_state

PYTHON код агента
import json

def main(state: dict) -> dict:
    shingles_factory = state['factory_ryazan_shingles']
    bom = shingles_factory['bom_per_ton']
    daily_target = shingles_factory['daily_production_target']
    raw_stock = shingles_factory['raw_materials_on_site']
    
    # 1. Суточная потребность
    daily_needs = {
        "bitumen": daily_target * bom["bitumen"],
        "basalt_granulate": daily_target * bom["basalt_granulate"],
        "fiberglass": daily_target * bom["fiberglass"]
    }
    
    # 2. Горизонт обеспеченности (на сколько дней хватит)
    days_left = {}
    for mat, need in daily_needs.items():
        days_left[mat] = round(raw_stock[mat] / need, 2) if need > 0 else 999

    # 3. Поиск самого "узкого" места
    critical_material = min(days_left, key=days_left.get)
    min_days = days_left[critical_material]
    
    # 4. ВВОДИМ ПОРОГ КРИТИЧНОСТИ
    # Если самому дефицитному материалу хватает на 3+ дня, то глобального кризиса НЕТ.
    is_critical = min_days < 3.0 
    
    # 5. Считаем объем дозаказа ТОЛЬКО если есть кризис
    if is_critical:
        needed_volume = (daily_needs[critical_material] * 3) - raw_stock[critical_material]
        needed_volume = max(0, needed_volume)
    else:
        needed_volume = 0 # В штатном режиме экстренный подвоз не нужен

    # 6. Логистические опции (формируем только для критичного материала)
    logistics_options = {}
    if critical_material == "bitumen":
        trucks = [t for t in state['trucks'] if t['base'] == 'Рязань' and t['material'] in ['Битум', 'Любой (Локальный)'] and t['status'] == 'available']
        logistics_options["direct_bitumen"] = {
            "route": "Завод Битума -> Завод Черепицы (Прямая)",
            "time_hours": 1,
            "available_trucks": [t['id'] for t in trucks],
            "source_stock": state['factory_ryazan_bitumen']['ready_to_ship_stock']
        }
    elif critical_material == "basalt_granulate":
        trucks = [t for t in state['trucks'] if t['base'] == 'Дзержинск' and t['status'] == 'available']
        logistics_options["warehouse_basalt"] = {
            "route": "Склад Дзержинск -> Завод Черепицы",
            "time_hours": 7,
            "available_trucks": [t['id'] for t in trucks],
            "source_stock": state['warehouse_dzerzhinsk']['current_stock']
        }
    elif critical_material == "fiberglass":
        trucks = [t for t in state['trucks'] if t['base'] == 'Рязань' and t['material'] in ['Стеклохолст', 'Любой (Локальный)'] and t['status'] == 'available']
        logistics_options["warehouse_fiberglass"] = {
            "route": "Склад Рязань -> Завод Черепицы (Локальная)",
            "time_hours": 1,
            "available_trucks": [t['id'] for t in trucks],
            "source_stock": state['warehouse_ryazan']['current_stock']
        }

    # 7. Контекст для LLM
    context = {
        "daily_target_shingles_tons": daily_target,
        "bom_analysis": {
            "bitumen_days_left": days_left['bitumen'],
            "basalt_days_left": days_left['basalt_granulate'],
            "fiberglass_days_left": days_left['fiberglass']
        },
        "bottleneck_material": critical_material,
        "bottleneck_days_left": min_days,
        "is_critical": is_critical,
        "needed_volume_tons": round(needed_volume, 2),
        "logistics_options": logistics_options,
        "incidents": state.get('incidents', [])
    }
    
    return {"planner_context": json.dumps(context, ensure_ascii=False)}

Исходящие переменные (Output Variables)

planner_context

Type = String

Шаг 4. Настройка LLM агента - LLM Thinker

Модель = gpt-4o-mini

Temperature = 0,4

Max Tokens = 512

Response Format = Text

Системный промпт
Ты — старший аналитик цепочки поставок производства гибкой черепицы.
Тебе передан цифровой контекст, где Python-алгоритм УЖЕ рассчитал спецификацию (BOM) и нашел "Бутылочное горлышко" (материал, который закончится раньше всего).

Тебе переданы:

1. Цифровой контекст (запасы, маршруты, машины).
2. Экстренные новости (текст от оператора).

Твоя задача:

1. Проверь поле `bottleneck_material`. Это твой главный фокус. Не трать время на материалы, которых хватает на 3+ дней.
2. Поле `is_critical` — это главный переключатель. Если `is_critical == false`, это значит, что запасы в норме (хватает на 3+ дня). В этом случае `alert_level` ДОЛЖЕН БЫТЬ "green" или "yellow". В `primary_action` напиши: "Штатная работа, экстренные доставки не требуются". Если `is_critical == true`, только тогда формируй план экстренной доставки.
3. Объем доставки: Ты ОБЯЗАН использовать значение из поля `needed_volume_tons` для объема доставки. НЕ ВЫДУМЫВАЙ объем
4. Если `needed_volume_tons == 0`, не предлагай никаких рейсов.
5. Посмотри на `logistics_options`. Там указан конкретный маршрут, время и список свободных машин ID.
6. Если свободных машин нет или на источнике (source_stock) нет нужного объема (`needed_volume_tons`), это КРИТИЧЕСКИЙ СБОЙ ЛОГИСТИКИ.
7. Сформулируй четкий план: какие именно машины (по ID из списка) нужно отправить, сколько тонн они повезут.
8. Если есть внешние новости (форс-мажоры), адаптируй план под них.

Выведи свои рассуждения обычным текстом шаг за шагом. Не пытайся сразу форматировать их в JSON.
Пользовательский контекст
Цифровой контекст: /planner_context
Экстренные новости: /breaking_news

Установить тумблер Structured у параметра OUTPUT Variables

Шаг 5. Настройка LLM агента - LLM Formatter

Модель = gpt-4o-mini

Temperature = 0,1

Max Tokens = 512

Response Format = JSON object

Системный помпт
Ты — строгий форматировщик данных. Тебе передали готовое решение и обоснование от старшего аналитика.
Твоя ЕДИНСТВЕННАЯ задача — перевести его решение в строгий JSON формат.

Используй ТОЛЬКО эти ключи:
{
"alert_level": "green" | "yellow" | "red",
"primary_action": "строка",
"estimated_time_hours": число,
"estimated_cost_rub": число,
"business_justification": "строка"
}
Никакого текста до или после JSON.
Пользовательский контекст
Готовое решение: /text

Установить тумблер Structured у параметра OUTPUT Variables

Шаг 6. Настройка генератора уведомлений

Входящие переменные (Input Variables)

state → Updater-agent.updated_state

recommendation → LLM-Formatter.text

PYTHON код агента
import json

def main(state: dict, recommendation: str) -> dict:
    # 1. Безопасно парсим ответ LLM
    rec_text = recommendation.replace('```json', '').replace('```', '').strip()
    try:
        rec = json.loads(rec_text)
    except Exception:
        rec = {}

    alert_level = str(rec.get('alert_level', 'red')).lower()
    primary_action = rec.get('primary_action', 'Ошибка: LLM не вернула план действий')
    justification = rec.get('business_justification', 'Обоснование не предоставлено')
    est_time = rec.get('estimated_time_hours', 'N/A')
    est_cost = rec.get('estimated_cost_rub', 'N/A')

    if alert_level not in ['green', 'yellow', 'red']:
        alert_level = 'red'
        
    alert_emoji = "🟢" if alert_level == 'green' else ("🟡" if alert_level == 'yellow' else "🔴")

    # 2. Достаем данные из новой архитектуры
    shingles = state['factory_ryazan_shingles']
    raw_mat = shingles['raw_materials_on_site']
    
    wh_ryazan = state['warehouse_ryazan']['current_stock']
    wh_dzerzhinsk = state['warehouse_dzerzhinsk']['current_stock']
    
    bitumen_ready = state['factory_ryazan_bitumen']['ready_to_ship_stock']
    basalt_ready = state['factory_uchaly_basalt']['ready_to_ship_stock']
    fiberglass_ready = state['factory_voskresensk_fiberglass']['ready_to_ship_stock']

    # 3. Формируем красивый Markdown-отчет
    markdown_report = f"""
### {alert_emoji} Статус производства: {alert_level.upper()}

#### 🏭 Завод гибкой черепицы (Рязань)
| Сырье / Продукт | Запас на заводе (т) | Готово к отгрузке (т) |
| :--- | :--- | :--- |
| **Битум** | {round(raw_mat['bitumen'], 1)} | {round(bitumen_ready, 1)} |
| **Базальтовая крошка** | {round(raw_mat['basalt_granulate'], 1)} | {round(basalt_ready, 1)} |
| **Стеклохолст** | {round(raw_mat['fiberglass'], 1)} | {round(fiberglass_ready, 1)} |
| **Готовая черепица** | {round(shingles['current_stock_shingles'], 1)} | - |

#### 📦 Склады и Промежуточные точки
| Локация | Материал | Текущий запас (т) |
| :--- | :--- | :--- |
| Склад (Рязань) | Стеклохолст | {round(wh_ryazan, 1)} |
| Склад (Дзержинск) | Базальтовая крошка | {round(wh_dzerzhinsk, 1)} |

🚨 **Активных инцидентов на маршрутах:** {len(state.get('incidents', []))}

---
#### 🚚 Рекомендация Планировщика
> **{primary_action}**

💡 *{justification}*

⏱ **Время:** {est_time} ч. | 💰 **Стоимость:** {est_cost} руб.
"""
    
    # ВАЖНО: Имя ключа 'html_content' должно совпадать с Output Variable в настройках узла Dify!
    return {"html_content": markdown_report}

Исходящие переменные (Output Variables)

html_content

Type = string

Шаг 7. Настройка вывода

Исходящие переменные (Output variable)

dashboard_md → Report_generator.md_content

Шаг 8. Запуск

Код для initial_state:
{
  "factory_uchaly_basalt": {
    "id": "F-UB-01",
    "material": "Базальтовая крошка",
    "city": "Учалы",
    "daily_output": 150,
    "current_output": 145,
    "status": "normal",
    "ready_to_ship_stock": 400
  },
  "factory_ryazan_bitumen": {
    "id": "F-RB-01",
    "material": "Битум",
    "city": "Рязань",
    "daily_output": 200,
    "current_output": 190,
    "status": "normal",
    "ready_to_ship_stock": 50
  },
  "factory_voskresensk_fiberglass": {
    "id": "F-VF-01",
    "material": "Стеклохолст",
    "city": "Воскресенск",
    "daily_output": 80,
    "current_output": 75,
    "status": "normal",
    "ready_to_ship_stock": 120
  },
  "factory_ryazan_shingles": {
    "id": "F-RS-01",
    "product": "Гибкая черепица",
    "city": "Рязань",
    "daily_production_target": 20,
    "current_stock_shingles": 15,
    "safety_stock_shingles": 10,
    "bom_per_ton": {
      "bitumen": 0.50,
      "basalt_granulate": 0.35,
      "fiberglass": 0.15
    },
    "raw_materials_on_site": {
      "bitumen": 8.0,
      "basalt_granulate": 5.0,
      "fiberglass": 2.5
    }
  },
  "warehouse_ryazan": {
    "id": "W-RZ-01",
    "material_stored": "Стеклохолст",
    "city": "Рязань",
    "current_stock": 25.0,
    "max_capacity": 100
  },
  "warehouse_dzerzhinsk": {
    "id": "W-DZ-01",
    "material_stored": "Базальтовая крошка",
    "city": "Дзержинск",
    "current_stock": 150.0,
    "max_capacity": 1000
  },
  "trucks": [
    {"id": "T-UB-01", "base": "Учалы", "capacity": 20, "status": "available", "material": "Базальтовая крошка"},
    {"id": "T-UB-02", "base": "Учалы", "capacity": 20, "status": "available", "material": "Базальтовая крошка"},
    {"id": "T-DZ-01", "base": "Дзержинск", "capacity": 15, "status": "available", "material": "Базальтовая крошка"},
    {"id": "T-DZ-02", "base": "Дзержинск", "capacity": 15, "status": "on_route", "eta_hours": 7, "material": "Базальтовая крошка"},
    {"id": "T-VF-01", "base": "Воскресенск", "capacity": 10, "status": "available", "material": "Стеклохолст"},
    {"id": "T-VF-02", "base": "Воскресенск", "capacity": 10, "status": "available", "material": "Стеклохолст"},
    {"id": "T-RZ-01", "base": "Рязань", "capacity": 5, "status": "available", "material": "Стеклохолст"},
    {"id": "T-RB-01", "base": "Рязань", "capacity": 10, "status": "available", "material": "Битум"},
    {"id": "T-RB-02", "base": "Рязань", "capacity": 10, "status": "maintenance", "eta_hours": 4, "material": "Битум"},
    {"id": "T-LOC-01", "base": "Рязань", "capacity": 5, "status": "available", "material": "Любой (Локальный)"},
    {"id": "T-LOC-02", "base": "Рязань", "capacity": 5, "status": "available", "material": "Любой (Локальный)"}
  ],
  "routes": [
    {"from": "Учалы", "to": "Дзержинск", "distance": 1100, "hours": 18, "cost_km": 40},
    {"from": "Дзержинск", "to": "Рязань", "distance": 450, "hours": 7, "cost_km": 45},
    {"from": "Воскресенск", "to": "Рязань", "distance": 180, "hours": 3, "cost_km": 45},
    {"from": "Рязань_Склад", "to": "Рязань_Завод", "distance": 20, "hours": 1, "cost_km": 60},
    {"from": "Рязань_Битум", "to": "Рязань_Завод", "distance": 15, "hours": 1, "cost_km": 60},
    {"from": "Учалы", "to": "Рязань", "distance": 1300, "hours": 20, "cost_km": 40}
  ],
  "incidents": []
}