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

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 23:34:20 +03:00
parent 597480adb9
commit 7209d2caab
37 changed files with 960 additions and 390 deletions
@@ -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(),
}
)