lct-hack/backend/app/scoring/grammar.py

102 lines
5.1 KiB
Python
Raw Normal View History

"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr."""
import json
import re
from dataclasses import dataclass
from app.config import get_settings
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
@dataclass(frozen=True)
class GrammarAssessment:
passed: bool
errors: tuple[str, ...]
source: str
def basic_check(text: str) -> GrammarAssessment:
"""Воспроизводимый минимум работает даже без модели."""
value = text.strip()
errors: list[str] = []
if not value:
errors.append("ответ отсутствует")
else:
first_letter = next((char for char in value if char.isalpha()), "")
if first_letter and first_letter != first_letter.upper():
errors.append("предложение начинается со строчной буквы")
if value[-1:] not in ".!?…":
errors.append("нет завершающего знака препинания")
if re.search(r"\s{2,}", value):
errors.append("повторяющиеся пробелы")
if re.search(r"[A-Za-z]", value):
errors.append("латинские буквы в русском ответе")
if re.search(r"([!?.,])\1{2,}", value):
errors.append("избыточно повторяющиеся знаки препинания")
agreement_errors = (
(r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"),
(r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"),
(r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"),
(r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"),
)
for pattern, message in agreement_errors:
if re.search(pattern, value, re.IGNORECASE):
errors.append(message)
return GrammarAssessment(not errors, tuple(errors), "rules")
async def assess(text: str) -> GrammarAssessment:
"""Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой."""
baseline = basic_check(text)
settings = get_settings()
if (not settings.grammar_llm_enabled or not text.strip()
or not is_loopback_url(
settings.llm_control_base_url,
allow_docker_host=settings.allow_docker_host_models,
)
or not settings.llm_model_control):
return baseline
schema = {"type": "json_object", "schema": {
"type": "object",
"properties": {
"passed": {"type": "boolean"},
"comment": {"type": "string"},
},
"required": ["passed", "comment"],
"additionalProperties": False,
}}
request = LlmRequest(
messages=[{
"role": "system",
"content": (
"Ты строгий корректор русского языка. Проверяй только грамматику, "
"орфографию и пунктуацию короткого служебного сообщения; не оценивай "
"факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, "
"comment=«Нарушено согласование: сообщение принято». Пример нормы: "
"«Сообщение принято, бригада направлена.» => passed=true, comment=«». "
"Найди даже одну языковую ошибку и верни JSON по заданной схеме."
),
}, {"role": "user", "content": text}],
model=settings.llm_model_control,
temperature=0.0,
max_tokens=160,
response_format=schema,
)
client = LlmClient(base_url=settings.llm_control_base_url, timeout=5)
try:
raw = await client.complete(request, use_cache=True)
payload = json.loads(raw)
if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool):
raise ValueError("неверная схема")
comment = payload["comment"]
if not isinstance(comment, str) or len(comment) > 500:
raise ValueError("неверный комментарий")
errors = list(baseline.errors)
if not payload["passed"]:
errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки")
return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr")
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
return baseline
finally:
await client.aclose()