ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация контуров, досок и Voice Tasker
This commit is contained in:
@@ -113,6 +113,32 @@ VOICE_TASK_STATE_GROUP_HINTS = {
|
||||
}
|
||||
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
TIME_PATTERN = re.compile(r"^\d{2}:\d{2}$")
|
||||
VOICE_TASK_MONTHS = {
|
||||
"январь": 1,
|
||||
"января": 1,
|
||||
"февраль": 2,
|
||||
"февраля": 2,
|
||||
"март": 3,
|
||||
"марта": 3,
|
||||
"апрель": 4,
|
||||
"апреля": 4,
|
||||
"май": 5,
|
||||
"мая": 5,
|
||||
"июнь": 6,
|
||||
"июня": 6,
|
||||
"июль": 7,
|
||||
"июля": 7,
|
||||
"август": 8,
|
||||
"августа": 8,
|
||||
"сентябрь": 9,
|
||||
"сентября": 9,
|
||||
"октябрь": 10,
|
||||
"октября": 10,
|
||||
"ноябрь": 11,
|
||||
"ноября": 11,
|
||||
"декабрь": 12,
|
||||
"декабря": 12,
|
||||
}
|
||||
VOICE_TASK_NUMBER_WORDS = {
|
||||
"один": 1,
|
||||
"одна": 1,
|
||||
@@ -152,6 +178,14 @@ VOICE_TASK_RELATIVE_DATE_PATTERN = re.compile(
|
||||
r"(?P<unit>день|дня|дней|сутки|суток|неделю|неделя|недели|недель|"
|
||||
r"месяц|месяца|месяцев|год|года|лет)(?![0-9a-zа-я])"
|
||||
)
|
||||
VOICE_TASK_MONTH_NAME_PATTERN = "|".join(sorted(VOICE_TASK_MONTHS.keys(), key=len, reverse=True))
|
||||
VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile(
|
||||
rf"(?<![0-9a-zа-я])(?P<day>[0-3]?\d)\s+(?P<month>{VOICE_TASK_MONTH_NAME_PATTERN})"
|
||||
r"(?:\s+(?P<year>\d{4}))?(?:\s+года?)?(?![0-9a-zа-я])"
|
||||
)
|
||||
VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile(
|
||||
r"(?<!\d)(?P<day>[0-3]?\d)[./-](?P<month>[01]?\d)(?:[./-](?P<year>\d{2,4}))?(?!\d)"
|
||||
)
|
||||
|
||||
|
||||
def normalize_audio_content_type(content_type):
|
||||
@@ -443,13 +477,15 @@ def transcript_has_project_routing_request(transcript):
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
normalized = normalized.lower()
|
||||
return bool(
|
||||
re.search(
|
||||
r"(проект|контур|перелож|перенес|перемест|перекин|route|move\s+to\s+project|project)",
|
||||
normalized,
|
||||
)
|
||||
normalized = normalize_match_value(normalized)
|
||||
if re.search(r"(проект|контур|route|move\s+to\s+project|project)", normalized):
|
||||
return True
|
||||
|
||||
has_transfer_verb = bool(re.search(r"(перелож|перенес|перемест|перекин|move)", normalized))
|
||||
has_due_marker = bool(
|
||||
re.search(r"(срок|дат|дедлайн|deadline|завтра|сегодня|послезавтра|вчера|дн(я|ей|ь)|недел|месяц|год|лет)", normalized)
|
||||
)
|
||||
return has_transfer_verb and not has_due_marker
|
||||
|
||||
|
||||
def transcript_contains_project_hint(project_hint, transcript):
|
||||
@@ -461,6 +497,19 @@ def transcript_contains_project_hint(project_hint, transcript):
|
||||
return normalized_hint in normalized_transcript
|
||||
|
||||
|
||||
def transcript_has_generic_memory_reference(transcript):
|
||||
normalized_transcript = normalize_match_value(transcript)
|
||||
if not normalized_transcript:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
re.search(
|
||||
r"\b(последн\w*|предыдущ\w*|прошл\w*|эту|эта|этой|этот|ее|её|его|ту|той)\b",
|
||||
normalized_transcript,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def infer_voice_task_project_from_transcript(projects, transcript):
|
||||
normalized_transcript = normalize_match_value(transcript)
|
||||
if not normalized_transcript:
|
||||
@@ -490,7 +539,7 @@ def infer_voice_task_project_from_transcript(projects, transcript):
|
||||
elif has_transfer_intent and re.search(r"(в|во|на)\s*$", prefix):
|
||||
score = 0.99
|
||||
|
||||
if score > best_score or (score == best_score and alias_length > best_alias_length):
|
||||
if score > 0 and (score > best_score or (score == best_score and alias_length > best_alias_length)):
|
||||
best_project = project
|
||||
best_score = score
|
||||
best_alias_length = alias_length
|
||||
@@ -501,6 +550,54 @@ def infer_voice_task_project_from_transcript(projects, transcript):
|
||||
return serialize_resolved_project(best_project, best_score, "transcript_project_hint")
|
||||
|
||||
|
||||
def infer_voice_task_source_project_from_transcript(projects, transcript):
|
||||
normalized_transcript = normalize_match_value(transcript)
|
||||
if not normalized_transcript:
|
||||
return None
|
||||
|
||||
has_transfer_intent = bool(re.search(r"(перелож|перенес|перемест|перекин|move|route)", normalized_transcript))
|
||||
best_project = None
|
||||
best_score = 0.0
|
||||
best_alias_length = 0
|
||||
|
||||
for project in projects:
|
||||
for candidate in get_project_alias_candidates(project):
|
||||
normalized_candidate = normalize_match_value(candidate)
|
||||
if len(normalized_candidate) < 3:
|
||||
continue
|
||||
|
||||
candidate_index = normalized_transcript.find(normalized_candidate)
|
||||
if candidate_index < 0:
|
||||
continue
|
||||
|
||||
prefix = normalized_transcript[max(0, candidate_index - 56) : candidate_index]
|
||||
alias_length = len(normalized_candidate)
|
||||
score = 0.0
|
||||
|
||||
if re.search(r"(из|с|со|from|source)\s+(?:проекта\s+|контура\s+)?$", prefix):
|
||||
score = 1.0
|
||||
elif re.search(
|
||||
r"(добав\w*|созда\w*|постав\w*)\s+(?:задач\w+\s+)?(в|во|на)\s+(?:проекте\s+|проект\s+|контуре\s+|контур\s+)?$",
|
||||
prefix,
|
||||
):
|
||||
score = 0.95
|
||||
elif (
|
||||
not has_transfer_intent
|
||||
and re.search(r"(в|во|на)\s+(?:проекте\s+|проект\s+|контуре\s+|контур\s+)?$", prefix)
|
||||
):
|
||||
score = 0.9
|
||||
|
||||
if score > 0 and (score > best_score or (score == best_score and alias_length > best_alias_length)):
|
||||
best_project = project
|
||||
best_score = score
|
||||
best_alias_length = alias_length
|
||||
|
||||
if not best_project:
|
||||
return None
|
||||
|
||||
return best_project
|
||||
|
||||
|
||||
def get_text_match_score(query, candidates):
|
||||
normalized_query = normalize_match_value(query)
|
||||
if not normalized_query:
|
||||
@@ -811,6 +908,61 @@ def add_months_to_date(value, months):
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def build_voice_task_date(day, month, year, current_date, year_was_explicit=False):
|
||||
try:
|
||||
day = int(day)
|
||||
month = int(month)
|
||||
year = int(year) if year else current_date.year
|
||||
if year < 100:
|
||||
year += 2000
|
||||
candidate = date(year, month, day)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if not year_was_explicit and candidate < current_date:
|
||||
try:
|
||||
candidate = date(current_date.year + 1, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return candidate.isoformat()
|
||||
|
||||
|
||||
def infer_voice_task_absolute_due_date(transcript, current_date):
|
||||
normalized = normalize_match_value(transcript)
|
||||
if normalized:
|
||||
match = VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN.search(normalized)
|
||||
if match:
|
||||
month = VOICE_TASK_MONTHS.get(match.group("month"))
|
||||
result = build_voice_task_date(
|
||||
day=match.group("day"),
|
||||
month=month,
|
||||
year=match.group("year"),
|
||||
current_date=current_date,
|
||||
year_was_explicit=bool(match.group("year")),
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
raw_transcript = normalize_string(transcript)
|
||||
if not raw_transcript:
|
||||
return None
|
||||
|
||||
match = VOICE_TASK_NUMERIC_DATE_PATTERN.search(raw_transcript.lower().replace("ё", "е"))
|
||||
if match:
|
||||
result = build_voice_task_date(
|
||||
day=match.group("day"),
|
||||
month=match.group("month"),
|
||||
year=match.group("year"),
|
||||
current_date=current_date,
|
||||
year_was_explicit=bool(match.group("year")),
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_voice_task_relative_due_date(transcript, current_date, target_issue=None):
|
||||
normalized = normalize_match_value(transcript)
|
||||
if not normalized:
|
||||
@@ -868,6 +1020,8 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No
|
||||
elif unit.startswith("месяц"):
|
||||
shift_months += quantity
|
||||
elif unit.startswith("год") or unit == "лет":
|
||||
if quantity >= 100:
|
||||
continue
|
||||
shift_months += quantity * 12
|
||||
|
||||
if shift_days == 0 and shift_months == 0:
|
||||
@@ -875,7 +1029,22 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No
|
||||
|
||||
base_date = current_date
|
||||
source_date = getattr(target_issue, "target_date", None)
|
||||
if source_date and any(marker in normalized for marker in ["подвин", "сдвин", "смест", "отлож", "раньше", "позже"]):
|
||||
has_existing_due_shift = any(
|
||||
marker in normalized
|
||||
for marker in [
|
||||
"подвин",
|
||||
"передвин",
|
||||
"сдвин",
|
||||
"смест",
|
||||
"отлож",
|
||||
"перенес",
|
||||
"назад",
|
||||
"вперед",
|
||||
"раньше",
|
||||
"позже",
|
||||
]
|
||||
)
|
||||
if source_date and has_existing_due_shift:
|
||||
base_date = source_date
|
||||
|
||||
result = add_months_to_date(base_date, shift_months * direction) if shift_months else base_date
|
||||
@@ -884,12 +1053,15 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No
|
||||
|
||||
|
||||
def hydrate_voice_task_due_date(draft, transcript, client_context, user, workspace, target_issue=None):
|
||||
if draft.get("due_date"):
|
||||
current_date = get_voice_task_current_date(client_context, user, workspace)
|
||||
absolute_due_date = infer_voice_task_absolute_due_date(transcript, current_date=current_date)
|
||||
if absolute_due_date:
|
||||
draft["due_date"] = absolute_due_date
|
||||
return
|
||||
|
||||
inferred_due_date = infer_voice_task_relative_due_date(
|
||||
transcript=transcript,
|
||||
current_date=get_voice_task_current_date(client_context, user, workspace),
|
||||
current_date=current_date,
|
||||
target_issue=target_issue,
|
||||
)
|
||||
if inferred_due_date:
|
||||
@@ -972,8 +1144,39 @@ def is_voice_task_issue_available(issue):
|
||||
return bool(issue and not issue.deleted_at and not issue.archived_at)
|
||||
|
||||
|
||||
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None):
|
||||
def get_committed_voice_task_memory_sessions(workspace, user, current_session=None):
|
||||
memory_sessions = (
|
||||
VoiceTaskSession.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
status=VoiceTaskSession.Status.PARSED,
|
||||
)
|
||||
.filter(Q(created_task__isnull=False) | Q(updated_task__isnull=False))
|
||||
.select_related("created_task", "created_task__project", "updated_task", "updated_task__project")
|
||||
.order_by("-updated_at", "-created_at")
|
||||
)
|
||||
if current_session:
|
||||
memory_sessions = memory_sessions.exclude(id=current_session.id)
|
||||
|
||||
return list(memory_sessions[: VOICE_TASK_MEMORY_LIMIT * 3])
|
||||
|
||||
|
||||
def find_latest_voice_task_issue(memory_sessions, project_id=None):
|
||||
for memory_session in memory_sessions:
|
||||
target_issue = get_voice_session_target_issue(memory_session)
|
||||
if not is_voice_task_issue_available(target_issue):
|
||||
continue
|
||||
if project_id and str(target_issue.project_id) != str(project_id):
|
||||
continue
|
||||
return target_issue, memory_session
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None):
|
||||
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
|
||||
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
|
||||
|
||||
if target_memory_ref:
|
||||
target_uuid = None
|
||||
@@ -989,7 +1192,7 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non
|
||||
.first()
|
||||
)
|
||||
target_issue = get_voice_session_target_issue(memory_session)
|
||||
if target_issue:
|
||||
if is_voice_task_issue_available(target_issue) and not generic_memory_reference:
|
||||
return target_issue, "target_memory_ref", memory_session
|
||||
|
||||
target_issue = (
|
||||
@@ -997,7 +1200,7 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non
|
||||
.select_related("project")
|
||||
.first()
|
||||
)
|
||||
if target_issue:
|
||||
if is_voice_task_issue_available(target_issue):
|
||||
return target_issue, "target_issue_id", None
|
||||
|
||||
issue_key_reference = parse_issue_key_reference(target_memory_ref)
|
||||
@@ -1012,26 +1215,25 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non
|
||||
.select_related("project")
|
||||
.first()
|
||||
)
|
||||
if target_issue:
|
||||
if is_voice_task_issue_available(target_issue):
|
||||
return target_issue, "target_issue_key", None
|
||||
|
||||
memory_sessions = (
|
||||
VoiceTaskSession.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
status=VoiceTaskSession.Status.PARSED,
|
||||
)
|
||||
.filter(Q(created_task__isnull=False) | Q(updated_task__isnull=False))
|
||||
.select_related("created_task", "created_task__project", "updated_task", "updated_task__project")
|
||||
.order_by("-updated_at", "-created_at")
|
||||
)
|
||||
if current_session:
|
||||
memory_sessions = memory_sessions.exclude(id=current_session.id)
|
||||
projects = list(get_accessible_projects(workspace, user).order_by("name"))
|
||||
source_project = infer_voice_task_source_project_from_transcript(projects, transcript)
|
||||
if source_project:
|
||||
target_issue, memory_session = find_latest_voice_task_issue(memory_sessions, source_project.id)
|
||||
if target_issue:
|
||||
return target_issue, "latest_voice_task_source_project", memory_session
|
||||
|
||||
for memory_session in memory_sessions[:VOICE_TASK_MEMORY_LIMIT * 3]:
|
||||
target_issue = get_voice_session_target_issue(memory_session)
|
||||
if is_voice_task_issue_available(target_issue):
|
||||
return target_issue, "latest_voice_task", memory_session
|
||||
current_project_id = normalize_string((client_context or {}).get("current_project_id"))
|
||||
if current_project_id:
|
||||
target_issue, memory_session = find_latest_voice_task_issue(memory_sessions, current_project_id)
|
||||
if target_issue:
|
||||
return target_issue, "latest_voice_task_current_project", memory_session
|
||||
|
||||
target_issue, memory_session = find_latest_voice_task_issue(memory_sessions)
|
||||
if target_issue:
|
||||
return target_issue, "latest_voice_task", memory_session
|
||||
|
||||
return None, None, None
|
||||
|
||||
@@ -1066,6 +1268,8 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
|
||||
user=user,
|
||||
draft=draft,
|
||||
current_session=voice_session,
|
||||
client_context=client_context,
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
hydrate_voice_task_due_date(
|
||||
@@ -1493,32 +1697,20 @@ def serialize_workspace_members(workspace):
|
||||
|
||||
|
||||
def serialize_recent_voice_memory(workspace, user):
|
||||
sessions = (
|
||||
VoiceTaskSession.objects.filter(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
status=VoiceTaskSession.Status.PARSED,
|
||||
)
|
||||
.exclude(parsed_json={})
|
||||
.select_related("created_task", "created_task__project", "updated_task", "updated_task__project")
|
||||
.order_by("-updated_at", "-created_at")[:VOICE_TASK_MEMORY_LIMIT]
|
||||
)
|
||||
sessions = get_committed_voice_task_memory_sessions(workspace, user)[:VOICE_TASK_MEMORY_LIMIT]
|
||||
|
||||
memory = []
|
||||
for session in sessions:
|
||||
target_issue = get_voice_session_target_issue(session)
|
||||
target_task = (
|
||||
serialize_voice_task_target(target_issue, "recent_voice_memory", session)
|
||||
if is_voice_task_issue_available(target_issue)
|
||||
else None
|
||||
)
|
||||
if not is_voice_task_issue_available(target_issue):
|
||||
continue
|
||||
memory.append(
|
||||
{
|
||||
"voice_session_id": str(session.id),
|
||||
"intent": session.intent,
|
||||
"title": session.parsed_json.get("title"),
|
||||
"project_hint": session.parsed_json.get("project_hint"),
|
||||
"target_task": target_task,
|
||||
"target_task": serialize_voice_task_target(target_issue, "recent_voice_memory", session),
|
||||
"created_at": session.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user