ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hard gate для voice update intent

This commit is contained in:
DCCONSTRUCTIONS 2026-04-25 09:16:13 +03:00
parent 7209d2caab
commit a9f2c53e89
2 changed files with 115 additions and 5 deletions

View File

@ -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

View File

@ -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"(?<![A-Za-z0-9])([A-Za-z0-9]+)-(\d+)(?![A-Za-z0-9])", raw_transcript))
def transcript_has_strong_existing_task_anchor(transcript):
normalized_transcript = normalize_match_value(transcript)
if not normalized_transcript:
return False
if transcript_has_issue_key_reference(transcript):
return True
task_anchor_patterns = [
r"\b(последн\w*|предыдущ\w*|прошл\w*|созданн\w*|добавленн\w*)\b.{0,48}\адач\w*\b",
r"\адач\w*\b.{0,48}\b(последн\w*|предыдущ\w*|прошл\w*|созданн\w*|добавленн\w*)\b",
r"\b(эту|эта|этой|этот|данную|данная|данной|ту|той)\b.{0,24}\адач\w*\b",
r"\адач\w*\b.{0,24}\b(эту|эта|этой|этот|данную|данная|данной|ту|той)\b",
r"\b(была|есть|существующ\w*)\b.{0,32}\адач\w*\b",
r"\адач\w*\b.{0,32}\b(была|есть|существующ\w*)\b",
]
return any(re.search(pattern, normalized_transcript) for pattern in task_anchor_patterns)
def transcript_looks_like_new_task_request(transcript):
normalized_transcript = normalize_match_value(transcript)
if not normalized_transcript:
return False
create_markers = [
r"\b(созда\w*|добав\w*|завед\w*|постав\w*)\b.{0,48}\адач\w*\b",
r"\адач\w*\b.{0,32}\b(следующ\w*|нов\w*|срочн\w*)\b",
r"\b(надо|нужно|необходимо|требуется)\b.{0,80}\b(сдела\w*|реализова\w*|добав\w*|передела\w*|подготов\w*|продум\w*)\b",
r"\b(исполнитель|ответственн\w*|приоритет|срок|реализац\w*)\b.{0,40}\b(сегодня|завтра|послезавтра|\d{4}-\d{2}-\d{2})\b",
r"\уда\s+надо\s+добав\w*\b",
]
return any(re.search(pattern, normalized_transcript) for pattern in create_markers)
def derive_voice_task_title_from_text(value):
normalized = normalize_string(value, 255)
if not normalized:
return None
parts = re.split(r"[.!?\n]+", normalized, maxsplit=1)
title = parts[0].strip(" :-,;")
title = re.sub(r"^(так|короче|слушай|пожалуйста|надо|нужно|необходимо)\b[\s,.-]*", "", title, flags=re.IGNORECASE)
title = title.strip(" :-,;")
if len(title) > 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"]: