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