@@ -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
|
||||
Reference in New Issue
Block a user