ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректная обработка русских Voice Tasker задач

This commit is contained in:
DCCONSTRUCTIONS 2026-04-26 14:35:16 +03:00
parent 8b230d2670
commit ee8b5123d8
3 changed files with 258 additions and 45 deletions

View File

@ -186,6 +186,39 @@ VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile(
VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile(
r"(?<!\d)(?P<day>[0-3]?\d)[./-](?P<month>[01]?\d)(?:[./-](?P<year>\d{2,4}))?(?!\d)"
)
VOICE_TASK_WEEKDAYS = {
"понедельник": 0,
"понедельника": 0,
"понедельнику": 0,
"monday": 0,
"вторник": 1,
"вторника": 1,
"вторнику": 1,
"tuesday": 1,
"среда": 2,
"среду": 2,
"среды": 2,
"wednesday": 2,
"четверг": 3,
"четверга": 3,
"четвергу": 3,
"thursday": 3,
"пятница": 4,
"пятницу": 4,
"пятницы": 4,
"friday": 4,
"суббота": 5,
"субботу": 5,
"субботы": 5,
"saturday": 5,
"воскресенье": 6,
"воскресенья": 6,
"воскресенью": 6,
"sunday": 6,
}
VOICE_TASK_WEEKDAY_PATTERN = re.compile(
rf"(?<![0-9a-zа-я])(?P<weekday>{'|'.join(sorted(VOICE_TASK_WEEKDAYS.keys(), key=len, reverse=True))})(?![0-9a-zа-я])"
)
def normalize_audio_content_type(content_type):
@ -358,6 +391,11 @@ class VoiceTaskParserService:
"For create_task, title must be a compact but meaning-preserving task name, not a 2-word summary. "
"description should be a detailed structured summary that preserves the user's meaning; "
"checklist should contain actionable bullet decomposition when the transcript includes multiple steps. "
"Return title, description, labels, checklist, and questions in the same natural language as "
"the transcript. If the transcript is Russian, all human-facing text must be Russian; never "
"translate Russian task text into English. "
"If the user says to assign the task to all employees/team members of a project/department, "
"set assignee_hint to all_project_members. "
"Use state_hint only for explicit status/state phrases like в работе, в реализации, active, backlog, done. "
"Do not infer state_hint from project names. If no status is requested, return null. "
"Never classify delete/remove/cancel-last-task commands as create_task. "
@ -570,7 +608,87 @@ def derive_voice_task_title_from_text(value):
return title or None
def voice_task_text_has_cyrillic(value):
return bool(normalize_string(value) and re.search(r"[А-Яа-яЁё]", value))
def voice_task_text_looks_latin(value):
normalized = normalize_string(value)
if not normalized:
return False
latin_count = len(re.findall(r"[A-Za-z]", normalized))
cyrillic_count = len(re.findall(r"[А-Яа-яЁё]", normalized))
return latin_count >= 12 and latin_count > cyrillic_count * 2
def derive_voice_task_title_from_transcript(transcript):
normalized = normalize_string(transcript, 1200)
if not normalized:
return None
action_verbs = [
"отправить",
"сдать",
"подготовить",
"закрыть",
"согласовать",
"проверить",
"добавить",
"создать",
"передать",
"позвонить",
"написать",
"сделать",
"разобрать",
"обновить",
"исправить",
"сверить",
"выгрузить",
"загрузить",
"оформить",
]
sentences = [sentence.strip(" :-,;") for sentence in re.split(r"[.!?\n]+", normalized) if sentence.strip()]
for sentence in sentences:
sentence_lower = sentence.lower().replace("ё", "е")
if "задач" not in sentence_lower:
continue
best_index = None
for verb in action_verbs:
match = re.search(rf"(?<![а-яa-z]){verb}(?![а-яa-z])", sentence_lower)
if match and (best_index is None or match.start() < best_index):
best_index = match.start()
if best_index is not None:
title = sentence[best_index:].strip(" :-,;")
return derive_voice_task_title_from_text(title)
return derive_voice_task_title_from_text(normalized)
def enforce_voice_task_output_language(parsed, transcript):
if parsed.get("intent") != "create_task" or not voice_task_text_has_cyrillic(transcript):
return parsed
if voice_task_text_looks_latin(parsed.get("title")):
parsed["title"] = derive_voice_task_title_from_transcript(transcript) or parsed.get("title")
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
if voice_task_text_looks_latin(parsed.get("description")):
parsed["description"] = normalize_string(transcript) or parsed.get("description")
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
parsed["checklist"] = [
item for item in parsed.get("checklist", []) if not voice_task_text_looks_latin(item)
]
parsed["questions"] = [
question for question in parsed.get("questions", []) if not voice_task_text_looks_latin(question)
]
return parsed
def harden_voice_task_intent(parsed, transcript):
parsed = enforce_voice_task_output_language(parsed, transcript)
if parsed.get("intent") != "update_task":
return parsed
@ -738,6 +856,14 @@ def serialize_resolved_assignee(user, confidence=0.0, source=None):
}
def get_voice_task_project_assignable_members(project):
return (
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
.select_related("member")
.order_by("member__display_name", "member__email")
)
def serialize_resolved_state(state, confidence=0.0, source=None):
if not state:
return None
@ -820,11 +946,7 @@ def resolve_voice_task_assignee(project, draft):
if not assignee_hint:
return None
project_members = (
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
.select_related("member")
.order_by("member__display_name", "member__email")
)
project_members = get_voice_task_project_assignable_members(project)
best_member = None
best_score = 0.0
for project_member in project_members:
@ -849,6 +971,33 @@ def resolve_voice_task_assignee(project, draft):
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
def transcript_requests_all_project_assignees(transcript, draft=None):
normalized = normalize_match_value(transcript)
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
if assignee_hint in {"all project members", "all_project_members", "all members", "all employees"}:
return True
if not normalized:
return False
patterns = [
r"\b(всех|всем|кажд\w*|all|everyone|everybody)\b.{0,48}\b(сотрудник\w*|участник\w*|исполнитель\w*|ответственн\w*|employees|members|assignees)\b",
r"\b(назнач\w*|ответственн\w*|исполнитель\w*)\b.{0,48}\b(всех|всем|кажд\w*|all|everyone|everybody)\b",
r"\b(сотрудник\w*|участник\w*|employees|members)\b.{0,24}\b(всех|всем|all|everyone|everybody)\b",
]
return any(re.search(pattern, normalized) for pattern in patterns)
def resolve_voice_task_assignees(project, draft, transcript=None):
if transcript_requests_all_project_assignees(transcript, draft):
return [
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
for project_member in get_voice_task_project_assignable_members(project)
]
assignee = resolve_voice_task_assignee(project, draft)
return [assignee] if assignee else []
def resolve_voice_task_labels(project, draft):
label_names = draft.get("labels") if isinstance(draft.get("labels"), list) else []
if not label_names:
@ -1059,6 +1208,33 @@ def infer_voice_task_absolute_due_date(transcript, current_date):
return None
def infer_voice_task_weekday_due_date(transcript, current_date):
normalized = normalize_match_value(transcript)
if not normalized:
return None
has_due_context = bool(
re.search(
r"\b(срок|дедлайн|deadline|выполнен\w*|выполнить|закончить|завершить|до|к|конц\w*|by|end)\b",
normalized,
)
)
for match in VOICE_TASK_WEEKDAY_PATTERN.finditer(normalized):
window_start = max(0, match.start() - 48)
window_end = min(len(normalized), match.end() + 32)
window = normalized[window_start:window_end]
if not has_due_context and not re.search(r"\b(до|к|на|в|во|by)\b", window):
continue
target_weekday = VOICE_TASK_WEEKDAYS[match.group("weekday")]
days_ahead = (target_weekday - current_date.weekday()) % 7
if days_ahead == 0 and re.search(r"\b(следующ\w*|next)\b", window):
days_ahead = 7
return (current_date + timedelta(days=days_ahead)).isoformat()
return None
def infer_voice_task_relative_due_date(transcript, current_date, target_issue=None):
normalized = normalize_match_value(transcript)
if not normalized:
@ -1155,6 +1331,11 @@ def hydrate_voice_task_due_date(draft, transcript, client_context, user, workspa
draft["due_date"] = absolute_due_date
return
weekday_due_date = infer_voice_task_weekday_due_date(transcript, current_date=current_date)
if weekday_due_date:
draft["due_date"] = weekday_due_date
return
inferred_due_date = infer_voice_task_relative_due_date(
transcript=transcript,
current_date=current_date,
@ -1444,10 +1625,12 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
warnings.append("project_hint_not_in_transcript")
resolved_assignee = None
resolved_assignees = []
resolved_labels = []
resolved_state = None
if project:
resolved_assignee = resolve_voice_task_assignee(project, draft)
resolved_assignees = resolve_voice_task_assignees(project, draft, transcript)
resolved_assignee = resolved_assignees[0] if len(resolved_assignees) == 1 else None
if resolved_assignee and resolved_assignee["confidence"] < VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
warnings.append("low_assignee_confidence")
resolved_labels = resolve_voice_task_labels(project, draft)
@ -1490,6 +1673,7 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
resolution = {
"project": resolved_project,
"assignee": resolved_assignee,
"assignees": resolved_assignees,
"labels": resolved_labels,
"state": resolved_state,
"target_task": serialize_voice_task_target(target_issue, target_source, target_session),
@ -1616,10 +1800,17 @@ def append_voice_task_description(existing_html, update_html):
def build_voice_task_issue_payload(draft, resolution, transcript=None):
project = resolution.get("project")
assignee = resolution.get("assignee")
assignees = resolution.get("assignees") or []
state = resolution.get("state")
labels = resolution.get("labels") or []
assignee_ids = []
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
if assignees:
assignee_ids = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
assignee_ids = [assignee["id"]]
return {
@ -1636,6 +1827,7 @@ def build_voice_task_issue_payload(draft, resolution, transcript=None):
def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=None):
assignee = resolution.get("assignee")
assignees = resolution.get("assignees") or []
state = resolution.get("state")
labels = resolution.get("labels") or []
project_change = resolution.get("project_change")
@ -1654,7 +1846,13 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
if draft.get("priority") and draft["priority"] != "none":
payload["priority"] = draft["priority"]
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
if assignees:
payload["assignee_ids"] = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
payload["assignee_ids"] = [assignee["id"]]
if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:

View File

@ -98,6 +98,23 @@ function getVoiceTaskWarnings(result: TVoiceTaskUploadResult) {
);
}
type TVoiceTaskResolutionWithAssignees = NonNullable<TVoiceTaskUploadResult["resolution"]> & {
assignees?: NonNullable<TVoiceTaskUploadResult["resolution"]>["assignee"][];
};
function getVoiceTaskAssigneesLabel(result: TVoiceTaskUploadResult) {
const resolution = result.resolution as TVoiceTaskResolutionWithAssignees | undefined;
const resolvedAssignees = resolution?.assignees?.length
? resolution.assignees
: resolution?.assignee
? [resolution.assignee]
: [];
if (resolvedAssignees.length > 0)
return resolvedAssignees.flatMap((assignee) => (assignee ? [assignee.name] : [])).join(", ");
return result.draft?.assignee_hint || "не распознано";
}
type Props = {
workspaceSlug: string;
};
@ -310,34 +327,31 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}
};
const refreshVisibleIssueStores = useCallback(
async () => {
const refreshes: Promise<unknown>[] = [];
const refreshVisibleIssueStores = useCallback(async () => {
const refreshes: Promise<unknown>[] = [];
if (activeProjectId) {
refreshes.push(refreshProjectIssues(workspaceSlug, activeProjectId, "mutation"));
if (activeProjectViewId) {
refreshes.push(refreshProjectViewIssues(workspaceSlug, activeProjectId, activeProjectViewId, "mutation"));
}
if (activeProjectId) {
refreshes.push(refreshProjectIssues(workspaceSlug, activeProjectId, "mutation"));
if (activeProjectViewId) {
refreshes.push(refreshProjectViewIssues(workspaceSlug, activeProjectId, activeProjectViewId, "mutation"));
}
}
if (activeGlobalViewId) {
refreshes.push(refreshGlobalIssues(workspaceSlug, activeGlobalViewId, "mutation"));
}
if (activeGlobalViewId) {
refreshes.push(refreshGlobalIssues(workspaceSlug, activeGlobalViewId, "mutation"));
}
if (!refreshes.length) return;
await Promise.allSettled(refreshes);
},
[
activeGlobalViewId,
activeProjectId,
activeProjectViewId,
refreshGlobalIssues,
refreshProjectIssues,
refreshProjectViewIssues,
workspaceSlug,
]
);
if (!refreshes.length) return;
await Promise.allSettled(refreshes);
}, [
activeGlobalViewId,
activeProjectId,
activeProjectViewId,
refreshGlobalIssues,
refreshProjectIssues,
refreshProjectViewIssues,
workspaceSlug,
]);
const commitVoiceTask = async () => {
if (!parseResult?.voice_session_id || !parseResult.draft) return;
@ -389,7 +403,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<button
type="button"
className={cn(
"pointer-events-auto flex size-11 items-center justify-center border-0 bg-transparent p-0 shadow-none outline-none transition",
"pointer-events-auto flex size-11 items-center justify-center border-0 bg-transparent p-0 shadow-none transition outline-none",
isAvailable
? "text-[rgb(var(--nodedc-accent-rgb))] hover:text-[rgb(var(--nodedc-card-active-rgb))]"
: "cursor-not-allowed text-tertiary"
@ -456,7 +470,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{parseResult.transcript && (
<div>
<div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
<p className="mt-1 whitespace-pre-wrap break-words text-12 leading-5 text-secondary">
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
{parseResult.transcript}
</p>
</div>
@ -504,9 +518,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"}
</div>
<div className="mt-0.5 text-primary">{getVoiceTaskAssigneesLabel(parseResult)}</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Срок</div>
@ -524,7 +536,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{parseResult.draft.description && (
<div>
<div className="text-11 font-medium text-tertiary uppercase">Описание</div>
<p className="mt-1 whitespace-pre-wrap break-words text-12 leading-5 text-secondary">
<p className="mt-1 text-12 leading-5 break-words whitespace-pre-wrap text-secondary">
{parseResult.draft.description}
</p>
</div>

View File

@ -104,13 +104,8 @@ export type TVoiceTaskDraft = {
export type TVoiceTaskResolution = {
project: TVoiceTaskResolvedProject | null;
assignee: {
id: string;
name: string;
email: string | null;
confidence: number;
source: string | null;
} | null;
assignee: TVoiceTaskResolvedAssignee | null;
assignees?: TVoiceTaskResolvedAssignee[];
labels: {
id: string;
name: string;
@ -149,6 +144,14 @@ export type TVoiceTaskResolvedProject = {
source: string | null;
};
export type TVoiceTaskResolvedAssignee = {
id: string;
name: string;
email: string | null;
confidence: number;
source: string | null;
};
export type TVoiceTaskUploadResult = {
ok: boolean;
status?: "uploaded" | "parsed";