From a9f2c53e890a9cb6a8d585173d04d159d6e94f52 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 09:16:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20hard=20gate=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?voice=20update=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs_prod/2_voicetasker/VOICETASKER_TECH.md | 14 ++- .../apps/api/plane/app/views/voice_tasker.py | 106 ++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/docs_prod/2_voicetasker/VOICETASKER_TECH.md b/docs_prod/2_voicetasker/VOICETASKER_TECH.md index 2b9a5c3..a16cdfa 100644 --- a/docs_prod/2_voicetasker/VOICETASKER_TECH.md +++ b/docs_prod/2_voicetasker/VOICETASKER_TECH.md @@ -968,11 +968,15 @@ Date resolver обязан работать после OpenAI parser как dete 1. explicit issue key/issue id остается самым сильным указанием цели; 2. `target_memory_ref` на voice-сессию используется только если эта сессия реально связана с доступной задачей; -3. если transcript содержит общее указание "последняя/предыдущая/эта", backend не доверяет model-selected voice session ref и выбирает цель deterministic fallback-ом; -4. если ref ведет в parsed/no-op сессию, resolver переходит к deterministic fallback; -5. fallback сначала учитывает явно названный source project; -6. затем текущий project из `client_context.current_project_id`; -7. затем последнюю примененную voice-задачу workspace. +3. `update_task/delete_task` разрешены только при сильном anchor на существующую задачу в transcript: issue key, "последняя/предыдущая задача", "эта задача", "существующая задача", "задача, которую добавили/создали"; +4. model-selected `target_memory_ref` на старую voice-сессию сам по себе не является anchor; +5. если модель вернула `update_task`, но transcript выглядит как новая постановка ("надо добавить", "задача срочная", исполнитель/контур/срок), backend переводит draft в `create_task`; +6. если transcript не выглядит как новая постановка и при этом нет anchor, commit блокируется с `unsafe_target_reference`; +7. если transcript содержит общее указание "последняя/предыдущая/эта задача", backend не доверяет model-selected voice session ref и выбирает цель deterministic fallback-ом; +8. если ref ведет в parsed/no-op сессию, resolver переходит к deterministic fallback; +9. fallback сначала учитывает явно названный source project; +10. затем текущий project из `client_context.current_project_id`; +11. затем последнюю примененную voice-задачу workspace. ### 10.5. Voice task representation in Issue diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index 40c835e..ab8d05e 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -349,6 +349,10 @@ class VoiceTaskParserService: "intent must be one of create_task, update_task, delete_task, unknown. " "For update_task/delete_task, use target_memory_ref from recent_voice_memory.voice_session_id " "or target_task.key when the transcript refers to a previous/latest task. " + "Default to create_task when the user describes new work, even if the work itself contains words " + "like redesign, rework, edit, fix, change, подредактировать, переделать. " + "Use update_task only when the transcript explicitly targets an existing task: issue key, " + "latest/previous task, this task, existing task, or a clearly referenced already-created task. " "For update_task, set title only when the user explicitly asks to rename the existing task; " "otherwise keep title null. " "For create_task, title must be a compact but meaning-preserving task name, not a 2-word summary. " @@ -510,6 +514,98 @@ def transcript_has_generic_memory_reference(transcript): ) +def transcript_has_issue_key_reference(transcript): + raw_transcript = normalize_string(transcript) + if not raw_transcript: + return False + + return bool(re.search(r"(? 140: + title = title[:137].rstrip(" ,-;:.") + "..." + return title or None + + +def harden_voice_task_intent(parsed, transcript): + if parsed.get("intent") != "update_task": + return parsed + + target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80) + has_explicit_issue_ref = bool(parse_issue_key_reference(target_memory_ref)) or transcript_has_issue_key_reference( + transcript + ) + has_strong_anchor = transcript_has_strong_existing_task_anchor(transcript) + if has_explicit_issue_ref or has_strong_anchor: + return parsed + + if transcript_looks_like_new_task_request(transcript): + parsed["intent"] = "create_task" + parsed["target_memory_ref"] = None + parsed["title"] = parsed.get("title") or derive_voice_task_title_from_text( + parsed.get("description") or transcript + ) + parsed["confidence"]["intent"] = min(parsed["confidence"].get("intent", 1.0), 0.85) + parsed["confidence"]["task"] = max(parsed["confidence"].get("task", 0.0), 0.85) + return parsed + + parsed["target_memory_ref"] = None + parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.49) + return parsed + + +def voice_task_has_safe_existing_task_anchor(draft, transcript): + target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80) + return bool( + parse_issue_key_reference(target_memory_ref) + or transcript_has_issue_key_reference(transcript) + or transcript_has_strong_existing_task_anchor(transcript) + ) + + def infer_voice_task_project_from_transcript(projects, transcript): normalized_transcript = normalize_match_value(transcript) if not normalized_transcript: @@ -1262,7 +1358,11 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont transcript = transcript or getattr(voice_session, "transcript", None) project_change = None + has_safe_existing_task_anchor = True if intent in {"update_task", "delete_task"}: + has_safe_existing_task_anchor = voice_task_has_safe_existing_task_anchor(draft, transcript) + + if intent in {"update_task", "delete_task"} and has_safe_existing_task_anchor: target_issue, target_source, target_session = resolve_voice_task_memory_target( workspace=workspace, user=user, @@ -1271,6 +1371,8 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont client_context=client_context, transcript=transcript, ) + elif intent in {"update_task", "delete_task"}: + warnings.append("unsafe_target_reference") hydrate_voice_task_due_date( draft=draft, @@ -1419,12 +1521,14 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont and "low_project_confidence" not in warnings and "state_not_resolved" not in warnings and "missing_update_fields" not in warnings + and "unsafe_target_reference" not in warnings ) elif intent == "delete_task": can_commit = bool( target_issue and is_voice_task_issue_available(target_issue) and "issue_permission_denied" not in warnings + and "unsafe_target_reference" not in warnings ) else: can_commit = False @@ -2057,6 +2161,7 @@ class VoiceTaskParseEndpoint(BaseAPIView): inferred_state_hint = infer_voice_task_state_hint(transcript) if inferred_state_hint: parsed["state_hint"] = inferred_state_hint + parsed = harden_voice_task_intent(parsed, transcript) warnings = get_voice_task_warnings(parsed, transcript) resolution = build_voice_task_resolution( @@ -2172,6 +2277,7 @@ class VoiceTaskCommitEndpoint(BaseAPIView): inferred_state_hint = infer_voice_task_state_hint(voice_session.transcript) if inferred_state_hint: draft["state_hint"] = inferred_state_hint + draft = harden_voice_task_intent(draft, voice_session.transcript) action = request.data.get("action") or draft["intent"] if action not in {"create_task", "update_task", "delete_task"} or action != draft["intent"]: