"""Сбор сжатого контекста для LLM: отчёты (загрузка/выгрузка), склад — те же правила, что в UI отчётов и sklad.""" from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import func, or_, select from app import db from app.models import ( ComponentStock, Ingredient, LoadingReport, LoadingReportComponent, Recipe, UnloadingReport, UnloadingReportGroup, ) LOADING_DEVIATION_PCT_STRONG = 10.0 UNLOADING_DEVIATION_PCT_STRONG = 5.0 def _parse_range(date_from: str, date_to: str) -> Tuple[datetime, datetime]: start = datetime.strptime(date_from.strip(), "%Y-%m-%d") end = datetime.strptime(date_to.strip(), "%Y-%m-%d") + timedelta(days=1) return start, end def _consumption_by_component( date_from: Optional[str], date_to: Optional[str] ) -> Dict[str, float]: q = ( db.session.query( LoadingReportComponent.component_id, func.coalesce( func.sum( LoadingReportComponent.actual_weight + func.coalesce(LoadingReportComponent.overload, 0.0) ), 0.0, ), ) .join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id) .filter(LoadingReport.is_deleted.is_(False)) .filter(LoadingReportComponent.is_deleted.is_(False)) .filter(LoadingReportComponent.actual_weight > 0) .filter(LoadingReportComponent.component_id.isnot(None)) ) if date_from and date_to: try: from_dt = datetime.strptime(date_from, "%Y-%m-%d") to_dt = datetime.strptime(date_to, "%Y-%m-%d").replace( hour=23, minute=59, second=59, microsecond=999999 ) q = q.filter( LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt, ) except ValueError: pass rows = q.group_by(LoadingReportComponent.component_id).all() out: Dict[str, float] = {} for cid, tot in rows: if cid: out[str(cid)] = float(tot or 0.0) return out def _planned_kg_per_day(component_id: str) -> float: if not component_id: return 0.0 q = ( db.session.query(Ingredient, Recipe) .join(Recipe, Recipe.id == Ingredient.recipe_id) .filter(Ingredient.component_id == component_id) .filter(Ingredient.is_deleted.is_(False)) .filter(Recipe.is_deleted.is_(False)) ) total = 0.0 for ing, recipe in q.all(): wph = float(ing.weight_per_head or 0) hpt = int(recipe.heads_per_trip or 0) tp = float(recipe.trip_percent or 100) / 100.0 total += wph * hpt * tp return round(total, 2) def _stock_lines(date_from: str, date_to: str, max_lines: int) -> List[str]: by_period = _consumption_by_component(date_from, date_to) by_all = _consumption_by_component(None, None) rows = db.session.execute( select(ComponentStock) .where(or_(ComponentStock.is_deleted.is_(None), ComponentStock.is_deleted == False)) .order_by(ComponentStock.sort_order.asc(), ComponentStock.component_name.asc()) ).scalars().all() lines: List[str] = [] for row in rows[: max_lines + 50]: cid = str(row.component_id) total_kg = max(0.0, float(row.total_kg)) inflow_kg = max(0.0, float(row.inflow_kg or 0)) consumed_all = by_all.get(cid, 0.0) baseline = abs(float(row.baseline_consumed_kg or 0)) consumed_since = max(0.0, consumed_all - baseline) available = total_kg + inflow_kg remaining = round(max(0.0, min(available, available - consumed_since)), 2) consumed_period = round(max(0.0, by_period.get(cid, 0.0)), 2) plan = _planned_kg_per_day(cid) days_left = "" if plan > 0 and remaining > 0: d = remaining / plan days_left = f", хватит ~{round(min(d, 9999.9), 1)} дн. при плане {plan} кг/сут." lines.append( f"- {row.component_name} (id {cid}): остаток ~{remaining} кг, расход за период {consumed_period} кг{days_left}" ) if len(lines) >= max_lines: break return lines def build_admin_llm_context( *, date_from: str, date_to: str, focus: str, max_chars: int, ) -> str: focus = (focus or "both").strip().lower() if focus not in ("both", "deviations", "stock"): focus = "both" parts: List[str] = [] if focus in ("both", "deviations"): start, end = _parse_range(date_from, date_to) reports = db.session.execute( select(LoadingReport) .where( LoadingReport.is_deleted.is_(False), LoadingReport.start_time >= start, LoadingReport.start_time < end, ) .order_by(LoadingReport.start_time.desc()) .limit(200) ).scalars().all() report_ids = [r.id for r in reports] loading_lines: List[str] = [] unload_lines: List[str] = [] mix_lines: List[str] = [] if report_ids: components = db.session.execute( select(LoadingReportComponent) .where( LoadingReportComponent.report_id.in_(report_ids), LoadingReportComponent.is_deleted.is_(False), ) .order_by(LoadingReportComponent.loading_order.asc()) ).scalars().all() by_report: Dict[str, List[LoadingReportComponent]] = {} for c in components: by_report.setdefault(c.report_id, []).append(c) unloading_rows = db.session.execute( select(UnloadingReport) .where( UnloadingReport.loading_report_id.in_(report_ids), UnloadingReport.is_deleted.is_(False), ) ).scalars().all() unloading_by_load = {u.loading_report_id: u for u in unloading_rows} u_ids = [u.id for u in unloading_rows] groups_by_u: Dict[str, List[UnloadingReportGroup]] = {} if u_ids: for g in db.session.execute( select(UnloadingReportGroup) .where( UnloadingReportGroup.report_id.in_(u_ids), UnloadingReportGroup.is_deleted.is_(False), ) .order_by(UnloadingReportGroup.order.asc()) ).scalars().all(): groups_by_u.setdefault(g.report_id, []).append(g) rep_by_id = {r.id: r for r in reports} for rid in report_ids: r = rep_by_id.get(rid) if not r: continue st = r.start_time.isoformat() if r.start_time else "" header = f"Загрузка {st} рецепт «{r.recipe_name}» id_отчёта={r.id}" if r.client_id: header += f" client_id={r.client_id}" for comp in by_report.get(rid, []): tw = float(comp.target_weight or 0) aw = float(comp.actual_weight or 0) if tw <= 0: continue dev_kg = aw - tw pct = (dev_kg / tw) * 100.0 if abs(pct) > LOADING_DEVIATION_PCT_STRONG: loading_lines.append( f"{header}: компонент «{comp.component_name}» план {tw:.2f} кг, факт {aw:.2f} кг, " f"отклонение {dev_kg:+.2f} кг ({pct:+.1f}%)" ) if r.target_mixing_time and r.actual_mixing_time: diff = int(r.actual_mixing_time) - int(r.target_mixing_time) if abs(diff) >= 30: mix_lines.append( f"{header}: время смешивания план {r.target_mixing_time} с, факт {r.actual_mixing_time} с " f"(Δ {diff:+d} с)" ) ur = unloading_by_load.get(rid) if ur: ust = ur.start_time.isoformat() if ur.start_time else "" for g in groups_by_u.get(ur.id, []): tgt = float(g.target_weight or 0) if tgt <= 0: continue uw = float(g.unloaded_weight or 0) dv = uw - tgt p = (dv / tgt) * 100.0 if abs(p) >= UNLOADING_DEVIATION_PCT_STRONG: unload_lines.append( f"Выгрузка {ust} группа «{g.name}» (к отчёту загрузки {rid}): план {tgt:.1f} кг, " f"выгружено {uw:.1f} кг, отклонение {dv:+.1f} кг ({p:+.1f}%)" ) parts.append( f"Период отчётов (UTC/БД): {date_from} — {date_to}. Всего отчётов загрузки в окне: {len(reports)}." ) if loading_lines: parts.append("Сильные отклонения по загрузке компонентов (|%| > 10 от плана):") parts.extend(loading_lines[:80]) else: parts.append("Сильных отклонений по загрузке компонентов за период нет.") if unload_lines: parts.append("Сильные отклонения по выгрузке групп (|%| ≥ 5 от плана):") parts.extend(unload_lines[:80]) if mix_lines: parts.append("Заметные отклонения времени смешивания (|Δ| ≥ 30 с):") parts.extend(mix_lines[:40]) if focus in ("both", "stock"): sl = _stock_lines(date_from, date_to, max_lines=40) if sl: parts.append("Склад (активные позиции, остаток и расход за выбранный период):") parts.extend(sl) else: parts.append("Склад: нет активных строк component_stock.") text = "\n".join(parts).strip() if len(text) > max_chars: text = text[: max_chars - 20] + "\n…(обрезано)" return text # Локальная Qwen малой размерности часто срывается в китайский шаблон («AI助手» и т.п.) — префикс дублируем жёстко. ADMIN_LLM_LANGUAGE_PREFIX_RU = ( "ЯЗЫК: только русский (кириллица). Запрещено: китайский, иероглифы, ответы вроде «AI助手», пиньинь, смесь EN+ZH. " "Пользователь общается по-русски — отвечай по-русски коротко (1–6 предложений), простыми словами: модель слабая. " "КРИТИЧНО: каждое слово ответа — русское; без китайских символов даже в приветствии. " "Не придумывай названия оборудования, рецептов, ферм и цифры, которых нет в переданных ниже блоках данных. " "Если данных из БД в сообщении нет — не выдумывай статусы SQLite, «таблицы ок», списки пунктов 1–16 и т.п.; скажи прямо, что фактов в запросе нет. " ) SYSTEM_PROMPT_RU = ( ADMIN_LLM_LANGUAGE_PREFIX_RU + "Ты помощник суперпользователя фермерской системы WESP (кормосмеситель, отчёты, склад). " "Отвечай по-русски кратко и по делу. Используй ТОЛЬКО факты из блока данных ниже; не выдумывай цифры и события. " "Если вопрос про состав БД (например «какие кормораздатчики», «какие рецепты», «какие таблицы») " "и в чате включены инструменты, сначала проверь БД через slim_sql, а уже потом отвечай. " "Не говори «нет информации», пока не попробовал хотя бы один SQL-запрос по теме. " "Если после проверки строк нет — так и скажи: в БД нет строк по этому критерию. " "Не давай ветеринарных/медицинских рекомендаций. " "На вопрос «кто ты» или «что ты»: ответь одним коротким абзацем по-русски, что ты локальный помощник WESP, без шаблонов на других языках." ) def ensure_russian_llm_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Добавляет/усиливает system на русском — без этого локальный Qwen часто отвечает по-китайски.""" if not messages: return messages prefix = ADMIN_LLM_LANGUAGE_PREFIX_RU if messages[0].get("role") == "system": first = dict(messages[0]) first["content"] = prefix + str(first.get("content") or "") return [first] + messages[1:] return [{"role": "system", "content": prefix.rstrip()}] + messages