refactor: step 2 - extract domain services (tasks, prompts, snapshots, knowledge)
This commit is contained in:
+30
-11
@@ -151,7 +151,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
if not unrecognized_scud_fios or not staff_fios:
|
||||
return {}
|
||||
|
||||
# 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен)
|
||||
# Исключаем точные совпадения
|
||||
staff_fios_set = set(staff_fios)
|
||||
real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
|
||||
|
||||
@@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
prompt = f"""
|
||||
Ты — кадровый аудитор безопасности СКУД.
|
||||
{rules_context}
|
||||
В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)}
|
||||
В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
|
||||
В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
|
||||
|
||||
СТРОГИЕ ПРАВИЛА:
|
||||
@@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
|
||||
4. В поле "warning" опиши обнаруженную опечатку.
|
||||
5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
|
||||
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON!
|
||||
|
||||
Формат ответа:
|
||||
ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
|
||||
{{
|
||||
"verified_matches": [
|
||||
{{
|
||||
"scud_fio": "ФИО из СКУД",
|
||||
"staff_fio": "эталон ФИО из Штат 1С",
|
||||
"warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'"
|
||||
"warning": "Описание опечатки в СКУД"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
raw_response = ask_ollama(
|
||||
prompt,
|
||||
system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки."
|
||||
system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
|
||||
)
|
||||
|
||||
mapping = {}
|
||||
if not raw_response:
|
||||
return mapping
|
||||
|
||||
# Фаза 1: Попытка прямого разбора с санитарной очисткой
|
||||
try:
|
||||
match = re.search(r'\{.*\}', raw_response, re.DOTALL)
|
||||
if match:
|
||||
json_str = match.group(0)
|
||||
json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str)
|
||||
# Убираем висячие запятые: {"a": 1,} -> {"a": 1}
|
||||
json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
|
||||
# Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
|
||||
json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
|
||||
|
||||
data = json.loads(json_str)
|
||||
for item in data.get("verified_matches", []):
|
||||
scud_f = item.get("scud_fio")
|
||||
staff_f = item.get("staff_fio")
|
||||
warn = item.get("warning", "Точное совпадение (без опечаток)")
|
||||
if scud_f and staff_f:
|
||||
if scud_f and staff_f and staff_f in staff_fios_set:
|
||||
mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
|
||||
return mapping
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
|
||||
try:
|
||||
pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
|
||||
matches = re.findall(pattern, raw_response, re.DOTALL)
|
||||
for scud_f, staff_f in matches:
|
||||
scud_clean = scud_f.strip()
|
||||
staff_clean = staff_f.strip()
|
||||
if staff_clean in staff_fios_set:
|
||||
mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
|
||||
except Exception as e:
|
||||
print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}")
|
||||
|
||||
print(f"[!] Ошибка резервного парсинга опечаток: {e}")
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/knowledge/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / knowledge
|
||||
ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from core.repositories.zup_repo import (
|
||||
get_all_rules_from_db,
|
||||
add_rule_to_db,
|
||||
get_department_synonyms_dict,
|
||||
add_department_synonym_to_db
|
||||
)
|
||||
|
||||
|
||||
def get_rules() -> List[Dict[str, Any]]:
|
||||
"""Получить все правила базы знаний в виде списка словарей."""
|
||||
raw_rules = get_all_rules_from_db()
|
||||
return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)]
|
||||
|
||||
|
||||
def add_rule(rule_text: str, added_by: str = "Human") -> None:
|
||||
"""Добавить новое правило в базу знаний."""
|
||||
add_rule_to_db(rule_text, added_by=added_by)
|
||||
|
||||
|
||||
def get_synonyms() -> Dict[str, str]:
|
||||
"""Получить словарь синонимов отделов."""
|
||||
return get_department_synonyms_dict()
|
||||
|
||||
|
||||
def register_department_synonym(short_name: str, full_name: str) -> None:
|
||||
"""Сохранить новую пару синонимов подразделения."""
|
||||
add_department_synonym_to_db(short_name, full_name)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/diff_engine.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff.
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Tuple, Dict
|
||||
from core.connection import get_connection
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_DIFF_BUILDER]
|
||||
def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]:
|
||||
"""
|
||||
Возвращает кортеж (merged_draft_text, diff_html_for_ui).
|
||||
"""
|
||||
act = (action or "ADD").upper()
|
||||
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND is_active = 1
|
||||
ORDER BY section_id, item_id
|
||||
""", (prompt_name,))
|
||||
existing_nodes = cursor.fetchall()
|
||||
|
||||
nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes}
|
||||
|
||||
# Формируем словарь для чистого текста
|
||||
nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict)
|
||||
if act != "DELETE":
|
||||
nodes_dict_for_draft[(section_id, item_id)] = content
|
||||
|
||||
draft_lines = []
|
||||
curr_sec = None
|
||||
for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
draft_lines.append("")
|
||||
draft_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
draft_lines.append(f" {s_id}.{i_id}. {txt}")
|
||||
merged_prompt = "\n".join(draft_lines)
|
||||
|
||||
# Формируем HTML Diff
|
||||
diff_lines = []
|
||||
curr_sec = None
|
||||
display_nodes = dict(nodes_dict)
|
||||
if act != "DELETE":
|
||||
display_nodes[(section_id, item_id)] = content
|
||||
|
||||
for (s_id, i_id), txt in sorted(display_nodes.items()):
|
||||
if i_id == 0:
|
||||
if curr_sec is not None:
|
||||
diff_lines.append("")
|
||||
diff_lines.append(f"{s_id}. {txt}")
|
||||
curr_sec = s_id
|
||||
else:
|
||||
if s_id == section_id and i_id == item_id:
|
||||
if act == "DELETE":
|
||||
line_str = f' <span class="line-through text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300 opacity-80">{s_id}.{i_id}. {txt} [УДАЛЕНИЕ]</span>'
|
||||
else:
|
||||
line_str = f' <span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-300">{s_id}.{i_id}. {txt}</span>'
|
||||
else:
|
||||
line_str = f" {s_id}.{i_id}. {txt}"
|
||||
diff_lines.append(line_str)
|
||||
|
||||
diff_html = "\n".join(diff_lines)
|
||||
return merged_prompt, diff_html
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/repository.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД.
|
||||
- ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Tuple, Dict, Any
|
||||
from core.connection import get_connection
|
||||
|
||||
logger = logging.getLogger("PROMPT_REPO")
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_REPO_GET_ACTIVE]
|
||||
def repo_get_active_prompt(prompt_name: str = "main_agent") -> str:
|
||||
"""Собирает структурированный текст системного промпта из активных узлов."""
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT section_id, item_id, content
|
||||
FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND is_active = 1
|
||||
ORDER BY section_id ASC, item_id ASC
|
||||
""", (prompt_name,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
return "Ты — ИИ-ассистент SCUD Orion AI."
|
||||
|
||||
lines = []
|
||||
current_section = None
|
||||
|
||||
for r in rows:
|
||||
sec_id = r["section_id"]
|
||||
itm_id = r["item_id"]
|
||||
content = r["content"]
|
||||
|
||||
if itm_id == 0:
|
||||
if current_section is not None:
|
||||
lines.append("")
|
||||
lines.append(f"{sec_id}. {content}")
|
||||
current_section = sec_id
|
||||
else:
|
||||
lines.append(f" {sec_id}.{itm_id}. {content}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ANCHOR[PROMPT_REPO_APPLY_ACTION]
|
||||
def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None:
|
||||
"""Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта."""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
action_clean = (action or "").upper()
|
||||
if action_clean in ["ADD", "UPDATE", "EDIT"]:
|
||||
cursor.execute("""
|
||||
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
is_active = 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (prompt_name, section_id, item_id, content))
|
||||
elif action_clean == "DELETE":
|
||||
cursor.execute("""
|
||||
DELETE FROM system_prompt_nodes
|
||||
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
|
||||
""", (prompt_name, section_id, item_id))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None:
|
||||
"""Парсит и полностью перезаписывает все узлы промпта из сырого текста."""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,))
|
||||
|
||||
current_sec = 1
|
||||
current_itm = 0
|
||||
|
||||
for raw_line in prompt_text.splitlines():
|
||||
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
|
||||
|
||||
if sub_match:
|
||||
current_sec = int(sub_match.group(1))
|
||||
current_itm = int(sub_match.group(2))
|
||||
content = sub_match.group(3).strip()
|
||||
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
|
||||
current_sec = int(sec_match.group(1))
|
||||
current_itm = 0
|
||||
content = sec_match.group(2).strip()
|
||||
else:
|
||||
current_itm += 1
|
||||
content = clean_line
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
""", (prompt_name, current_sec, current_itm, content))
|
||||
|
||||
conn.commit()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/prompts/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / prompts
|
||||
ROLE: Единый доменный сервис управления системным промптом.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Tuple, Dict, Any
|
||||
from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt
|
||||
from .diff_engine import build_prompt_diff
|
||||
|
||||
|
||||
def get_active_system_prompt() -> str:
|
||||
"""Получить текущий активный системный промпт."""
|
||||
return repo_get_active_prompt()
|
||||
|
||||
|
||||
def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None:
|
||||
"""Применить точечное изменение к узлу промпта."""
|
||||
repo_apply_prompt_action(action, section_id, item_id, content)
|
||||
|
||||
|
||||
def save_full_prompt_draft(draft_text: str) -> None:
|
||||
"""Сохранить полный черновик промпта."""
|
||||
repo_save_full_prompt(draft_text)
|
||||
|
||||
|
||||
def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]:
|
||||
"""
|
||||
Формирует черновик и diff.
|
||||
Возвращает (merged_draft, diff_html, baseline_prompt).
|
||||
"""
|
||||
baseline = repo_get_active_prompt()
|
||||
draft, diff_html = build_prompt_diff(action, section_id, item_id, content)
|
||||
return draft, diff_html, baseline
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/exporter.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / tasks
|
||||
ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from config import OUTPUT_DIR
|
||||
from .repository import repo_get_tasks
|
||||
|
||||
logger = logging.getLogger("TASK_EXPORTER")
|
||||
|
||||
|
||||
# ANCHOR[TASK_EXPORT_MARKDOWN]
|
||||
def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/."""
|
||||
tasks = repo_get_tasks(user_id)
|
||||
if not tasks:
|
||||
return {"status": "error", "message": "Список задач пуст, экспорт отменен"}
|
||||
|
||||
# 1. Фильтрация задач по статусу
|
||||
if status_filter and status_filter.upper() != "ALL":
|
||||
tgt = status_filter.upper()
|
||||
if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]]
|
||||
elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]]
|
||||
elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]:
|
||||
tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]]
|
||||
|
||||
if not tasks:
|
||||
return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"}
|
||||
|
||||
target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md"
|
||||
if not target_filename.endswith(".md"):
|
||||
target_filename = f"{target_filename}.md"
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 2. Группировка по модулям
|
||||
modules: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for t in tasks:
|
||||
mod = t.get("module") or "general"
|
||||
modules.setdefault(mod, []).append(t)
|
||||
|
||||
md_lines = [
|
||||
"# 🗺️ Дорожная карта задач проекта (ROADMAP)\n",
|
||||
f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n",
|
||||
"---\n"
|
||||
]
|
||||
|
||||
for mod_name, mod_tasks in sorted(modules.items()):
|
||||
md_lines.append(f"## Модуль `{mod_name}`\n")
|
||||
for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)):
|
||||
status = str(t.get("status", "BACKLOG")).upper()
|
||||
is_done = status in ["COMPLETED", "DONE"]
|
||||
is_progress = status in ["IN_PROGRESS", "PROGRESS"]
|
||||
|
||||
check_box = "[x]" if is_done else "[ ]"
|
||||
t_id = t.get("id")
|
||||
title = t.get("title", "Без названия")
|
||||
prio = t.get("priority", "MEDIUM")
|
||||
due = f" *(срок: {t['due_date']})*" if t.get("due_date") else ""
|
||||
status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "")
|
||||
|
||||
md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}")
|
||||
|
||||
md_lines.append("\n---\n")
|
||||
|
||||
content = "\n".join(md_lines)
|
||||
|
||||
# 3. Сохранение файла в изолированную сессионную папку
|
||||
tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export")
|
||||
os.makedirs(tool_dir, exist_ok=True)
|
||||
|
||||
session_token = uuid.uuid4().hex[:8]
|
||||
session_dir = os.path.join(tool_dir, session_token)
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(session_dir, target_filename)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": target_filename,
|
||||
"filepath": filepath,
|
||||
"download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}",
|
||||
"tasks_count": len(tasks),
|
||||
"message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})."
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/repository.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / tasks
|
||||
ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю.
|
||||
- ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID.
|
||||
- ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи.
|
||||
- ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from core.connection import get_connection
|
||||
|
||||
|
||||
def normalize_task_id(task_id_input: str) -> str:
|
||||
"""Нормализует идентификатор задачи к формату TASK-XX."""
|
||||
if not task_id_input:
|
||||
return ""
|
||||
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
|
||||
if clean_id.isdigit():
|
||||
num = int(clean_id)
|
||||
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
|
||||
return f"TASK-{clean_id}"
|
||||
|
||||
|
||||
# ANCHOR[TASK_REPO_GET]
|
||||
def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Получает список задач пользователя с опциональной фильтрацией по статусу."""
|
||||
conn = get_connection(row_factory=True)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if status and status.upper() != "ALL":
|
||||
target_status = status.upper()
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ"]:
|
||||
target_status = "BACKLOG"
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
|
||||
ORDER BY id DESC
|
||||
""", (user_id, target_status, target_status))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT id, task_id, module, title, priority, status, due_date, created_at
|
||||
FROM tasks
|
||||
WHERE user_id = ?
|
||||
ORDER BY id DESC
|
||||
""", (user_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ANCHOR[TASK_REPO_ADD]
|
||||
def repo_add_task(
|
||||
user_id: int,
|
||||
module: str,
|
||||
title: str,
|
||||
priority: str = "MEDIUM",
|
||||
due_date: Optional[str] = None,
|
||||
status: str = "BACKLOG"
|
||||
) -> Dict[str, Any]:
|
||||
"""Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT MAX(id) FROM tasks")
|
||||
max_id = cursor.fetchone()[0] or 0
|
||||
new_task_id = f"TASK-{(max_id + 1):02d}"
|
||||
|
||||
target_status = status.upper() if status else "BACKLOG"
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
|
||||
target_status = "BACKLOG"
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
|
||||
|
||||
|
||||
# ANCHOR[TASK_REPO_UPDATE]
|
||||
def repo_update_task(
|
||||
user_id: int,
|
||||
task_id: str,
|
||||
title: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
due_date: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Комплексное обновление атрибутов задачи."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
clean_num = re.sub(r'\D', '', str(task_id))
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if title is not None and title.strip():
|
||||
updates.append("title = ?")
|
||||
params.append(title.strip())
|
||||
|
||||
if priority is not None and priority.strip():
|
||||
updates.append("priority = ?")
|
||||
params.append(priority.strip().upper())
|
||||
|
||||
if status is not None and status.strip():
|
||||
target_status = status.strip().upper()
|
||||
if target_status in ["PROGRESS", "В РАБОТЕ"]:
|
||||
target_status = "IN_PROGRESS"
|
||||
elif target_status in ["DONE", "ГОТОВО"]:
|
||||
target_status = "COMPLETED"
|
||||
elif target_status in ["PLANNED", "ПЛАНЫ"]:
|
||||
target_status = "BACKLOG"
|
||||
updates.append("status = ?")
|
||||
params.append(target_status)
|
||||
|
||||
if due_date is not None:
|
||||
updates.append("due_date = ?")
|
||||
params.append(due_date.strip() if due_date.strip() else None)
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return {"status": "success", "message": "Нет данных для обновления"}
|
||||
|
||||
params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
|
||||
sql = f"""
|
||||
UPDATE tasks
|
||||
SET {', '.join(updates)}
|
||||
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
"""
|
||||
cursor.execute(sql, params)
|
||||
rows_affected = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if rows_affected == 0:
|
||||
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
|
||||
|
||||
return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
|
||||
|
||||
|
||||
# ANCHOR[TASK_REPO_DELETE]
|
||||
def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
"""Удаляет задачу по номеру ID."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
clean_num = re.sub(r'\D', '', str(task_id))
|
||||
formatted_id = normalize_task_id(task_id)
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM tasks
|
||||
WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||
""", (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
|
||||
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if deleted == 0:
|
||||
return {"error": f"Задача {task_id} не найдена"}
|
||||
return {"status": "success", "message": f"Задача #{task_id} удалена"}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/tasks/service.py
|
||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||
MODULE: services / tasks
|
||||
ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций).
|
||||
|
||||
AI-CONTEXT-ANCHORS:
|
||||
- ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task
|
||||
from .exporter import export_tasks_to_markdown
|
||||
|
||||
|
||||
def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Получить список задач."""
|
||||
return repo_get_tasks(user_id, status)
|
||||
|
||||
|
||||
def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]:
|
||||
"""Создать задачу."""
|
||||
res = repo_add_task(user_id, module, title, priority, due_date, status)
|
||||
return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"}
|
||||
|
||||
|
||||
def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Обновить задачу."""
|
||||
return repo_update_task(user_id, task_id, title, priority, status, due_date)
|
||||
|
||||
|
||||
def delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||
"""Удалить задачу."""
|
||||
return repo_delete_task(user_id, task_id)
|
||||
|
||||
|
||||
# ANCHOR[TASK_SERVICE_DISPATCHER]
|
||||
def execute_task_action(
|
||||
user_id: int,
|
||||
action: str,
|
||||
task_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
priority: Optional[str] = "MEDIUM",
|
||||
status: Optional[str] = None,
|
||||
module: Optional[str] = "general",
|
||||
due_date: Optional[str] = None,
|
||||
filename: Optional[str] = "ROADMAP.md"
|
||||
) -> Dict[str, Any]:
|
||||
"""Консолидированный диспетчер операций над задачами."""
|
||||
act = (action or "").strip().upper()
|
||||
|
||||
if act == "ADD":
|
||||
if not title:
|
||||
return {"status": "error", "message": "Для создания задачи требуется указать title"}
|
||||
return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG")
|
||||
|
||||
elif act == "UPDATE":
|
||||
if not task_id:
|
||||
return {"status": "error", "message": "Для обновления требуется указать task_id"}
|
||||
return update_task_details(user_id, str(task_id), title, priority, status, due_date)
|
||||
|
||||
elif act == "DELETE":
|
||||
if not task_id:
|
||||
return {"status": "error", "message": "Для удаления требуется указать task_id"}
|
||||
return delete_task(user_id, str(task_id))
|
||||
|
||||
elif act == "EXPORT":
|
||||
return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status)
|
||||
|
||||
return {"status": "error", "message": f"Неизвестное действие action='{action}'"}
|
||||
Reference in New Issue
Block a user