lct-hack/backend/app/scoring/grammar.py
2026-09-24 01:10:49 +03:00

102 lines
5.1 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.

"""Проверка письменного ответа ДДС: локальные правила и необязательный 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()