diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 6012962..e9187e2 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -77,6 +77,32 @@ return any("Ѐ" <= ch <= "ӿ" for ch in text) +# Служебные слова: их потеря при сокращении заголовка потерей информации не считается +_DROPPED_STOPWORDS = { + "и", "в", "на", "с", "со", "для", "по", "за", "из", "от", "до", "у", "к", "о", + "не", "чтобы", "как", "это", "при", "the", "a", "an", "to", "for", "so", "of", + "in", "on", "at", "it", "is", "be", +} + + +def _details_dropped(original: str, shortened: str) -> bool: + """True, если при сокращении заголовка потеряны содержательные слова исходника. + + Сравнение подстрокой (регистронезависимо): морфологию маленькая модель может + изменить («валидацию» → «Валидация») — ложное «потеряно» не страшно, страховка + просто положит исходный заголовок в описание. Числа учитываются любой длины — + их выкидывание и есть главный случай потери деталей. + """ + short = shortened.lower() + for word in re.findall(r"\w+", original.lower()): + if word.isdigit(): + if word not in short: + return True + elif len(word) >= 3 and word not in _DROPPED_STOPWORDS and word not in short: + return True + return False + + def build_prompt( title: str, description: str, @@ -124,12 +150,16 @@ rules = [ "title: сделай из исходного заголовка короткое лаконичное название " "(до 60 знаков): убери лишние детали, числа и вводные слова, оставь суть. " - "Пиши по-русски. Если заголовок уже короткий и ясный — верни null.", + "Всё, что убрал из заголовка (условия, числа, уточнения), перенеси в " + "description — ничего не теряй. Пиши по-русски. Если заголовок уже " + "короткий и ясный — верни null.", ] if include_description: rules.append( "description: предложи понятное описание с нумерованным списком шагов " - "(markdown, 1. 2. 3.) по-русски. Если шаги неочевидны — верни null." + "(markdown, 1. 2. 3.) по-русски; если убрал что-то из заголовка — " + "обязательно включи это. Если шаги неочевидны и в заголовке нечего " + "было убирать — верни null." ) if include_tags: rules.append( @@ -141,10 +171,13 @@ if projects_ctx: rules.append("Контекст проектов — только для понимания темы, проект выбирать не нужно.") - # few-shot пример в языке промпта: маленькие модели копируют формат и язык примера + # few-shot пример в языке промпта: маленькие модели копируют формат и язык примера. + # description НЕ null: пример учит переносить выброшенные из заголовка детали example_fields = ['"title": "Валидация email в форме регистрации"'] if include_description: - example_fields.append('"description": null') + example_fields.append( + '"description": "Форма регистрации не должна пропускать некорректные email адреса"' + ) if include_tags: example_fields.append('"tags": ["validation", "email"]') example = "{" + ", ".join(example_fields) + "}" @@ -182,13 +215,17 @@ rules = [ "title: turn the original title into a short concise name (up to 60 chars): " - "drop extra details, numbers and filler words, keep the essence. If the " - "title is already short and clear — return null.", + "drop extra details, numbers and filler words, keep the essence. Move " + "everything you dropped (conditions, numbers, clarifications) into the " + "description — do not lose anything. If the title is already short and " + "clear — return null.", ] if include_description: rules.append( "description: propose a clear description with a numbered list of steps " - "(markdown, 1. 2. 3.). If the steps are not obvious — return null." + "(markdown, 1. 2. 3.); include anything you dropped from the title. If " + "the steps are not obvious and nothing was dropped from the title — " + "return null." ) if include_tags: rules.append( @@ -203,7 +240,9 @@ example_fields = ['"title": "Registration email validation"'] if include_description: - example_fields.append('"description": null') + example_fields.append( + '"description": "The registration form must not accept invalid email addresses"' + ) if include_tags: example_fields.append('"tags": ["validation", "email"]') example = "{" + ", ".join(example_fields) + "}" @@ -288,6 +327,18 @@ if not include_description: new_description = None + # Страховка потери информации: промпт требует переносить выброшенное из + # заголовка в описание, но маленькие модели нарушают. Если заголовок + # изменён, описание не предложено, а содержательные слова исходника + # пропали — складываем в описание исходный заголовок целиком. + if ( + new_title + and not new_description + and include_description + and _details_dropped(title, new_title) + ): + new_description = title.strip() + # Теги: новые разрешены (спрос — придумать подходящие), но мусор и дубли — нет tags: list[str] = [] if include_tags: diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index b48e621..b96854d 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -158,6 +158,111 @@ assert "тегов" not in calls[0] +def test_propose_moves_dropped_title_details_to_description( + client: TestClient, monkeypatch: Any +) -> None: + """Модель сократила заголовок, а детали (условия, числа) выкинула — исходный + заголовок складывается в описание, ничего не теряется (страховка промпта).""" + service = services.detailing.DetailingService(base_url="http://mock", model="m") + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"title": "Валидация email", "description": null, "tags": []}', + ) + proposal = service.propose( + "Переделать валидацию формы регистрации чтобы она не пропускала " + "некорректные email адреса", + "", + [], + [], + ) + assert proposal is not None + assert proposal["title"] == "Валидация email" + assert proposal["description"] == ( + "Переделать валидацию формы регистрации чтобы она не пропускала " + "некорректные email адреса" + ) + + +def test_propose_dropped_number_falls_back_to_original_title( + client: TestClient, monkeypatch: Any +) -> None: + """Выкинутое из заголовка число — тоже потерянная деталь.""" + service = services.detailing.DetailingService(base_url="http://mock", model="m") + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"title": "Позвонить в сервис", "description": null, "tags": []}', + ) + proposal = service.propose("Позвонить в сервис по номеру 4455", "", [], []) + assert proposal is not None + assert proposal["description"] == "Позвонить в сервис по номеру 4455" + + +def test_propose_no_fallback_when_nothing_lost( + client: TestClient, monkeypatch: Any +) -> None: + """Все содержательные слова на месте (модель дала своё описание, либо заголовок + переформулирован без потерь) — исходник в описание не дублируется.""" + service = services.detailing.DetailingService(base_url="http://mock", model="m") + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"title": "Купить молоко и хлеб", "description": null, "tags": []}', + ) + proposal = service.propose("Купить молоко", "", [], []) + assert proposal is not None + assert proposal["title"] == "Купить молоко и хлеб" + assert proposal["description"] is None + + # модель сама перенесла детали в описание — страховка не нужна + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"title": "Валидация email", ' + '"description": "Форма не должна пропускать некорректные email", "tags": []}', + ) + proposal = service.propose( + "Переделать валидацию формы регистрации чтобы она не пропускала " + "некорректные email адреса", + "", + [], + [], + ) + assert proposal is not None + assert proposal["description"] == "Форма не должна пропускать некорректные email" + + +def test_propose_no_fallback_when_description_filled_by_user( + client: TestClient, monkeypatch: Any +) -> None: + """include_description=False (описание уже заполнено пользователем) — страховка + не трогает заполненное поле.""" + service = services.detailing.DetailingService(base_url="http://mock", model="m") + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"title": "Коротко", "tags": []}', + ) + proposal = service.propose( + "длинный заголовок с лишними деталями про числа 42", "моё описание", [], [], + include_description=False, + ) + assert proposal is not None + assert proposal["title"] == "Коротко" + assert proposal["description"] is None + + +def test_build_prompt_keeps_dropped_details_rule() -> None: + """Промпт (ru и en) требует переносить выброшенное из заголовка в описание.""" + ru = build_prompt("Переделать валидацию формы регистрации", "", [], []) + assert "перенеси" in ru + assert "не пропускала некорректные email адреса" in ru # пример учит переносу + en = build_prompt("Redo registration form validation", "", [], []) + assert "Move" in en + assert "must not accept invalid email addresses" in en + + def test_apply_proposal_maps_and_creates(client: TestClient) -> None: client.post("/api/tags", json={"name": "быт"}) diff --git a/docs/TZ.md b/docs/TZ.md index 07df63d..5932f29 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.45 | +| Версия ТЗ | 0.46 | | Дата | 2026-09-23 | | Статус | На обсуждении | @@ -55,7 +55,11 @@ (0.40: только оформление задачи, без планирования): - короткий заголовок — длинный/перегруженный заголовок сжимается до лаконичного - названия, суть переносится в описание; + названия; **выброшенное не теряется (0.46)**: условия, числа и уточнения, + убранные из заголовка, переносятся в описание; если модель сократила заголовок, + но описания не предложила, — в описание автоматически кладётся исходный + заголовок (страховка от потери информации, при отключённом описании — не + срабатывает); - описание с нумерованными шагами — если шаги выполнения очевидны; - теги — максимум 3: подходящие из существующего справочника или 1–2 новых; **новые теги — только на английском**;