diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 5387457..6012962 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -70,6 +70,13 @@ return re.sub(r"[ \t]{2,}", " ", text)[:max_len] +# Кириллица в тексте задачи → весь промпт на русском. Маленькие модели мимикрируют +# под язык промпта — правило «отвечай по-русски» внутри английского промпта они +# игнорируют (проверено на qwen3.5:2b, 2026-09-22), а весь русский промпт держит. +def _is_cyrillic(text: str) -> bool: + return any("Ѐ" <= ch <= "ӿ" for ch in text) + + def build_prompt( title: str, description: str, @@ -83,9 +90,30 @@ include_description/include_tags — поля, которых у задачи ещё нет (заполненное пользователем не угадываем). Заголовок просим всегда: сжатие длинного - заголовка — главная полезная работа LLM. + заголовка — главная полезная работа LLM. Промпт строится на языке задачи + (см. _is_cyrillic) — так модель отвечает на языке исходника. """ projects_ctx = "\n".join(f"- {p['name']}: {p['summary']}" for p in projects) + if _is_cyrillic(f"{title} {description}"): + return _prompt_ru( + title, description, tag_names, projects_ctx, + include_description=include_description, include_tags=include_tags, + ) + return _prompt_en( + title, description, tag_names, projects_ctx, + include_description=include_description, include_tags=include_tags, + ) + + +def _prompt_ru( + title: str, + description: str, + tag_names: list[str], + projects_ctx: str, + *, + include_description: bool, + include_tags: bool, +) -> str: fields = ['"title": "короткий заголовок или null"'] if include_description: fields.append('"description": "описание с шагами или null"') @@ -94,16 +122,14 @@ schema = ", ".join(fields) rules = [ - # язык исходных данных — главный источник промахов маленькой модели - "Пиши на том же языке, что и текст задачи (заголовок и описание).", "title: сделай из исходного заголовка короткое лаконичное название " "(до 60 знаков): убери лишние детали, числа и вводные слова, оставь суть. " - "Если заголовок уже короткий и ясный — верни null.", + "Пиши по-русски. Если заголовок уже короткий и ясный — верни null.", ] if include_description: rules.append( "description: предложи понятное описание с нумерованным списком шагов " - "(markdown, 1. 2. 3.). Если шаги неочевидны — верни null." + "(markdown, 1. 2. 3.) по-русски. Если шаги неочевидны — верни null." ) if include_tags: rules.append( @@ -113,19 +139,87 @@ "необходимости и не повторяй смысл." ) if projects_ctx: - rules.append( - "Контекст проектов ниже — только для понимания темы задачи, проект " - "выбирать не нужно." - ) + rules.append("Контекст проектов — только для понимания темы, проект выбирать не нужно.") + + # few-shot пример в языке промпта: маленькие модели копируют формат и язык примера + example_fields = ['"title": "Валидация email в форме регистрации"'] + if include_description: + example_fields.append('"description": null') + if include_tags: + example_fields.append('"tags": ["validation", "email"]') + example = "{" + ", ".join(example_fields) + "}" return ( "Ты — ассистент личного таск-менеджера. Помоги оформить новую задачу.\n\n" "Правила:\n- " + "\n- ".join(rules) + "\n\n" + "Пример:\n" + 'Задача: "Переделать валидацию формы регистрации чтобы она не пропускала ' + 'некорректные email адреса"\n' + f"Ответ: {example}\n\n" + (f"Контекст проектов:\n{projects_ctx}\n\n" if projects_ctx else "") + f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n\n" f'Задача: "{title}"\n' + (f'Описание: "{description}"\n\n' if description else "") - + f'Ответь ТОЛЬКО JSON вида: {{{schema}}}' + + f"Ответь ТОЛЬКО JSON вида: {{{schema}}}" + ) + + +def _prompt_en( + title: str, + description: str, + tag_names: list[str], + projects_ctx: str, + *, + include_description: bool, + include_tags: bool, +) -> str: + fields = ['"title": "short title or null"'] + if include_description: + fields.append('"description": "steps description or null"') + if include_tags: + fields.append('"tags": ["tag", ...]') + schema = ", ".join(fields) + + 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.", + ] + 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." + ) + if include_tags: + rules.append( + f"tags: up to {MAX_TAGS} short tags. Reuse fitting existing tags; propose new " + "tags in English only, nominative case (e.g. cleanup, testing, shopping). " + "Do not invent tags without need and do not repeat meanings." + ) + if projects_ctx: + rules.append( + "Project context below is only for understanding the topic — do not pick a project." + ) + + example_fields = ['"title": "Registration email validation"'] + if include_description: + example_fields.append('"description": null') + if include_tags: + example_fields.append('"tags": ["validation", "email"]') + example = "{" + ", ".join(example_fields) + "}" + + return ( + "You are a personal task manager assistant. Help format a new task.\n\n" + "Rules:\n- " + "\n- ".join(rules) + "\n\n" + "Example:\n" + 'Task: "Redo the registration form validation so it does not accept invalid ' + 'email addresses"\n' + f"Answer: {example}\n\n" + + (f"Project context:\n{projects_ctx}\n\n" if projects_ctx else "") + + f"Existing tags: {json.dumps(tag_names, ensure_ascii=False)}\n\n" + f'Task: "{title}"\n' + + (f'Description: "{description}"\n\n' if description else "") + + f"Answer ONLY as JSON: {{{schema}}}" )