290 lines
14 KiB
Python
290 lines
14 KiB
Python
"""Сбор сжатого контекста для 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
|