Files
site/WESP_REL/app/services/admin_llm_context.py
T
2026-07-17 12:57:18 +03:00

290 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Сбор сжатого контекста для 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