Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+289
View File
@@ -0,0 +1,289 @@
"""Сбор сжатого контекста для 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