ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректная обработка русских Voice Tasker задач
This commit is contained in:
parent
8b230d2670
commit
ee8b5123d8
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,8 +327,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
}
|
||||
};
|
||||
|
||||
const refreshVisibleIssueStores = useCallback(
|
||||
async () => {
|
||||
const refreshVisibleIssueStores = useCallback(async () => {
|
||||
const refreshes: Promise<unknown>[] = [];
|
||||
|
||||
if (activeProjectId) {
|
||||
|
|
@ -327,8 +343,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
|
||||
if (!refreshes.length) return;
|
||||
await Promise.allSettled(refreshes);
|
||||
},
|
||||
[
|
||||
}, [
|
||||
activeGlobalViewId,
|
||||
activeProjectId,
|
||||
activeProjectViewId,
|
||||
|
|
@ -336,8 +351,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue