ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hard gate для voice update intent
This commit is contained in:
parent
7209d2caab
commit
a9f2c53e89
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}\bзадач\w*\b",
|
||||
r"\bзадач\w*\b.{0,48}\b(последн\w*|предыдущ\w*|прошл\w*|созданн\w*|добавленн\w*)\b",
|
||||
r"\b(эту|эта|этой|этот|данную|данная|данной|ту|той)\b.{0,24}\bзадач\w*\b",
|
||||
r"\bзадач\w*\b.{0,24}\b(эту|эта|этой|этот|данную|данная|данной|ту|той)\b",
|
||||
r"\b(была|есть|существующ\w*)\b.{0,32}\bзадач\w*\b",
|
||||
r"\bзадач\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}\bзадач\w*\b",
|
||||
r"\bзадач\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"\bкуда\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"]:
|
||||
|
|
|
|||
Loading…
Reference in New Issue