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()
|