diff --git a/backend/alembic/versions/09f0fefd5d6a_m4_estimates_and_budget.py b/backend/alembic/versions/09f0fefd5d6a_m4_estimates_and_budget.py new file mode 100644 index 0000000..7553e23 --- /dev/null +++ b/backend/alembic/versions/09f0fefd5d6a_m4_estimates_and_budget.py @@ -0,0 +1,38 @@ +"""m4 estimates and budget + +Revision ID: 09f0fefd5d6a +Revises: b0dc582ec93e +Create Date: 2026-09-19 20:52:39.117782 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '09f0fefd5d6a' +down_revision: str | Sequence[str] | None = 'b0dc582ec93e' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tasks', sa.Column('estimated_minutes', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('actual_minutes', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('budget_money', sa.Integer(), nullable=True)) + op.add_column('tasks', sa.Column('cost_estimate_money', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tasks', 'cost_estimate_money') + op.drop_column('tasks', 'budget_money') + op.drop_column('tasks', 'actual_minutes') + op.drop_column('tasks', 'estimated_minutes') + # ### end Alembic commands ### diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index cb9163d..4a2211d 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -7,8 +7,9 @@ from app.dependencies import DbDep, UserDep from app.models import Project, Tag, Task, utcnow -from app.schemas import ApproveIn, TaskCreate, TaskOut, TaskUpdate +from app.schemas import ApproveIn, SuggestIn, TaskCreate, TaskOut, TaskUpdate from app.services.detailing import apply_proposal, detail_task +from app.services.predict import pick_options, predict_minutes router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -155,6 +156,22 @@ return task +@router.post("/{task_id}/predict", response_model=TaskOut) +async def predict_task(task_id: int, db: DbDep, user: UserDep) -> Task: + """Пересчитать прогноз длительности по истории похожих завершённых задач.""" + task = _get_task_or_404(db, task_id) + task.estimated_minutes = predict_minutes(db, task) + db.flush() + db.refresh(task) + return task + + +@router.post("/suggest", response_model=list[TaskOut]) +async def suggest_tasks(schema: SuggestIn, db: DbDep, user: UserDep) -> list[Task]: + """Режим «3 варианта»: до трёх задач под доступное время.""" + return pick_options(db, schema.available_minutes) + + @router.delete("/{task_id}") async def delete_task(task_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: db.delete(_get_task_or_404(db, task_id)) diff --git a/backend/app/models.py b/backend/app/models.py index 68a398b..15c618c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -45,6 +45,15 @@ ) priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + # Прогнозы и бюджет (M4). estimated_minutes приходит из LLM-детализации + # или истории завершённых задач; actual — фактическая затрата времени. + estimated_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + actual_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + # Бюджет опционален (ТЗ 3.4): деньги задаёт пользователь, оценка затрат — рядом + # (целые рубли — дробные деньги в личном планировании не нужны) + budget_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + cost_estimate_money: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + tags: Mapped[list["Tag"]] = relationship( secondary="task_tags", back_populates="tasks", lazy="selectin" ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c96f533..9fd34ac 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -41,6 +41,10 @@ tag_ids: list[int] | None = None priority: int | None = None status: str | None = None + estimated_minutes: int | None = Field(None, ge=1, le=24 * 60) + actual_minutes: int | None = Field(None, ge=1, le=24 * 60) + budget_money: int | None = Field(None, ge=0) + cost_estimate_money: int | None = Field(None, ge=0) class TaskOut(BaseModel): @@ -57,6 +61,10 @@ priority: int | None tags: list[TagOut] ai_proposal: dict[str, Any] | None + estimated_minutes: int | None + actual_minutes: int | None + budget_money: int | None + cost_estimate_money: int | None created_at: datetime approved_at: datetime | None done_at: datetime | None @@ -68,6 +76,12 @@ apply_proposal: bool = False +class SuggestIn(BaseModel): + """Режим «3 варианта»: доступное время в минутах.""" + + available_minutes: int = Field(ge=5, le=24 * 60) + + # --- Project / Tag --- diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 784fbca..c52e4c1 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -19,6 +19,7 @@ logger = logging.getLogger(__name__) MAX_TAGS = 3 +MAX_ESTIMATE_MINUTES = 24 * 60 PRIORITY_SCALE = "10 — срочно и важно; 7 — важно; 4 — обычное; 1 — когда-нибудь; null — неясно" @@ -34,9 +35,11 @@ "или предложи краткое название нового проекта, если ни один не подходит.\n\n" f'Задача: "{title}"\n' f'Описание: "{description}"\n\n' - f"Шкала приоритета: {PRIORITY_SCALE}.\n\n" + f"Шкала приоритета: {PRIORITY_SCALE}.\n" + "Оцени длительность задачи в минутах (целое число, 1–1440; null — неясно).\n\n" 'Ответь ТОЛЬКО JSON вида: {"tags": ["тег", ...], "project": "имя или null", ' - '"new_project": true или false, "priority": число или null}' + '"new_project": true или false, "priority": число или null, ' + '"estimated_minutes": число или null}' ) @@ -79,6 +82,10 @@ if priority is not None and not (isinstance(priority, int) and 0 <= priority <= 10): priority = None + estimated = data.get("estimated_minutes") + if not (isinstance(estimated, int) and 1 <= estimated <= MAX_ESTIMATE_MINUTES): + estimated = None + project = data.get("project") if not isinstance(project, str) or not project.strip(): project = None @@ -88,6 +95,7 @@ "project": project, "new_project": bool(data.get("new_project")) or project not in project_names, "priority": priority, + "estimated_minutes": estimated, } @@ -117,6 +125,9 @@ if proposal.get("priority") is not None: task.priority = proposal["priority"] + if proposal.get("estimated_minutes") is not None: + task.estimated_minutes = proposal["estimated_minutes"] + def detail_task(task_id: int) -> None: """Фоновая работа: сгенерировать и сохранить предложение для задачи в стеке.""" diff --git a/backend/app/services/predict.py b/backend/app/services/predict.py new file mode 100644 index 0000000..1b04f34 --- /dev/null +++ b/backend/app/services/predict.py @@ -0,0 +1,86 @@ +"""Прогнозирование времени и режим «3 варианта» (M4). + +Прогноз — медиана фактической длительности похожих завершённых задач +(похожесть: общий проект или хотя бы один тег). Без истории — None. +LLM-оценка приходит в предложении детализации и применяется при утверждении. +""" + +import statistics +from typing import Any + +from sqlalchemy import select + +from app.models import Project, Task + + +def predict_minutes(db: Any, task: Task) -> int | None: + """Прогноз длительности по истории похожих завершённых задач.""" + candidates: list[int] = [] + task_tag_ids = [t.id for t in task.tags] + done = list( + db.scalars( + select(Task).where(Task.status == "done", Task.actual_minutes.isnot(None)) + ).all() + ) + for other in done: + if other.id == task.id: + continue + similar = other.project_id is not None and other.project_id == task.project_id + if not similar and task_tag_ids and other.tags: + similar = bool(set(t.id for t in other.tags) & set(task_tag_ids)) + if similar: + candidates.append(other.actual_minutes or 0) + candidates = [c for c in candidates if c > 0] + if not candidates: + return None + return int(round(statistics.median(candidates))) + + +def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]: + """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время. + + Кандидаты: утверждённые, к выполнению/в работе, проект активен (или без + проекта). Сортировка: приоритет, затем свежесть. Разнообразие: максимум + одна задача на проект; сначала строго укладывающиеся в время. + """ + stmt = ( + select(Task) + .where(Task.detail_state == "approved", Task.status.in_(("to_do", "in_progress"))) + .order_by(Task.priority.desc().nullslast(), Task.created_at.asc()) + ) + tasks = list(db.scalars(stmt).all()) + + active_projects: set[int | None] = {None} + for p in db.scalars(select(Project)).all(): + if p.relevance_status == "active": + active_projects.add(p.id) + + used_projects: set[int | None] = set() + picked_ids: set[int] = set() + picked: list[Task] = [] + # сначала — строго укладывающиеся в доступное время + for t in tasks: + if len(picked) >= limit: + break + if t.project_id not in active_projects or t.project_id in used_projects: + continue + if t.estimated_minutes is not None and t.estimated_minutes > available_minutes: + continue + picked.append(t) + picked_ids.add(t.id) + used_projects.add(t.project_id) + # добираем задачи без оценки длительности (её нельзя отсечь по времени) + if len(picked) < limit: + for t in tasks: + if len(picked) >= limit: + break + if t.estimated_minutes is not None: + continue # с оценкой: либо уложились в первый проход, либо не влезли + if t.id in picked_ids or t.project_id not in active_projects: + continue + if t.project_id in used_projects: + continue + picked.append(t) + picked_ids.add(t.id) + used_projects.add(t.project_id) + return picked[:limit] diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py new file mode 100644 index 0000000..9f628b2 --- /dev/null +++ b/backend/tests/test_predict.py @@ -0,0 +1,140 @@ +"""Тесты M4: прогноз времени по истории, бюджет, режим «3 варианта».""" + +from typing import Any + +from fastapi.testclient import TestClient + +from app.models import Task +from app.services.predict import pick_options, predict_minutes +from tests.conftest import _test_session_factory + + +def test_session() -> Any: + return _test_session_factory() + + +def _db_task(task_id: int) -> Task: + session = _test_session_factory() + task = session.get(Task, task_id) + session.close() + assert task is not None + return task + + +def _mkdone( + client: TestClient, + title: str, + actual: int, + project_id: int | None = None, + tag_id: int | None = None, +) -> None: + tid = client.post("/api/tasks", json={"title": title}).json()["id"] + patch: dict[str, Any] = {"status": "done", "actual_minutes": actual} + if project_id: + patch["project_id"] = project_id + if tag_id: + patch["tag_ids"] = [tag_id] + assert client.patch(f"/api/tasks/{tid}", json=patch).status_code == 200 + + +def test_predict_median_by_project(client: TestClient) -> None: + pid = client.post("/api/projects", json={"name": "Кухня"}).json()["id"] + _mkdone(client, "Помыть посуду", 20, project_id=pid) + _mkdone(client, "Протереть стол", 30, project_id=pid) + _mkdone(client, "Разобрать ящик", 40, project_id=pid) + + fresh = client.post("/api/tasks", json={"title": "Убрать кухню"}).json()["id"] + client.patch(f"/api/tasks/{fresh}", json={"project_id": pid}) + + predicted = predict_minutes(test_session(), _db_task(fresh)) + assert predicted == 30 # медиана 20, 30, 40 + + +def test_predict_none_without_history(client: TestClient) -> None: + fresh = client.post("/api/tasks", json={"title": "Новое"}).json()["id"] + assert predict_minutes(test_session(), _db_task(fresh)) is None + + +def test_predict_endpoint_and_manual_fields(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Задача с бюджетом"}).json()["id"] + updated = client.patch( + f"/api/tasks/{tid}", + json={"budget_money": 5000, "cost_estimate_money": 3500, "actual_minutes": 90}, + ).json() + assert updated["budget_money"] == 5000 + assert updated["cost_estimate_money"] == 3500 + assert updated["actual_minutes"] == 90 + + # нет истории — прогноз не появился, эндпоинт отвечает задачей с null + predicted = client.post(f"/api/tasks/{tid}/predict").json() + assert predicted["estimated_minutes"] is None + + +def test_pick_options_fit_time_and_diversity(client: TestClient) -> None: + p1 = client.post("/api/projects", json={"name": "Дом"}).json()["id"] + p2 = client.post("/api/projects", json={"name": "Работа"}).json()["id"] + + def approve(title: str, minutes: int | None, project_id: int | None) -> None: + tid = client.post("/api/tasks", json={"title": title}).json()["id"] + patch: dict[str, Any] = {} + if minutes is not None: + patch["estimated_minutes"] = minutes + if project_id is not None: + patch["project_id"] = project_id + client.patch(f"/api/tasks/{tid}", json=patch) + client.post(f"/api/tasks/{tid}/approve") + + approve("Мелочь 30м", 30, p1) + approve("Средняя 60м", 60, p2) + approve("Большая 300м", 300, None) + approve("Без оценки", None, p1) + # приостановленный проект исключается + paused = client.post("/api/projects", json={"name": "Заморожено"}).json()["id"] + client.patch(f"/api/projects/{paused}", json={"relevance_status": "paused"}) + approve("Из замороженного", 15, paused) + + options = pick_options(test_session(), 90) + titles = [t.title for t in options] + assert len(options) <= 3 + assert "Мелочь 30м" in titles + assert "Средняя 60м" in titles + assert "Большая 300м" not in titles # не влезает в 90 минут + assert "Из замороженного" not in titles # проект неактивен + # разнообразие: не более одной задачи на проект + projects_used = [t.project_id for t in options] + assert len(projects_used) == len(set(projects_used)) + + +def test_pick_options_skips_done_and_cancelled(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Завершённая"}).json()["id"] + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + client.post(f"/api/tasks/{tid}/approve") + + options = pick_options(test_session(), 600) + assert all(t.id != tid for t in options) + + +def test_suggest_endpoint(client: TestClient) -> None: + resp = client.post("/api/tasks/suggest", json={"available_minutes": 120}) + assert resp.status_code == 200 + assert len(resp.json()) <= 3 + # валидация времени + assert client.post("/api/tasks/suggest", json={"available_minutes": 0}).status_code == 422 + + +def test_proposal_estimate_applied(client: TestClient, monkeypatch: Any) -> None: + def fake_propose( + self: Any, title: str, description: str, tag_names: list[str], project_names: list[str] + ) -> dict[str, Any]: + return { + "tags": [], + "project": None, + "new_project": False, + "priority": 7, + "estimated_minutes": 45, + } + + monkeypatch.setattr("app.services.detailing.DetailingService.propose", fake_propose) + tid = client.post("/api/tasks", json={"title": "Что-то на 45 минут"}).json()["id"] + approved = client.post(f"/api/tasks/{tid}/approve", json={"apply_proposal": True}).json() + assert approved["estimated_minutes"] == 45 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 719ec08..9a52649 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,7 @@ { id: 'tree', label: 'Дерево задач', icon: 'ph-tree-structure', to: '/tree' }, { id: 'list', label: 'Список задач', icon: 'ph-list-bullets', to: '/list' }, { id: 'projects', label: 'Проекты', icon: 'ph-folders', to: '/projects' }, + { id: 'options', label: '3 варианта', icon: 'ph-shuffle', to: '/options' }, ] const currentLabel = computed(() => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8f24502..288300a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -19,6 +19,7 @@ project: string | null new_project: boolean priority: number | null + estimated_minutes?: number | null } export interface Attachment { @@ -42,6 +43,10 @@ priority: number | null tags: Tag[] ai_proposal: AiProposal | null + estimated_minutes: number | null + actual_minutes: number | null + budget_money: number | null + cost_estimate_money: number | null created_at: string approved_at: string | null done_at: string | null @@ -55,6 +60,10 @@ tag_ids?: number[] priority?: number | null status?: string + estimated_minutes?: number | null + actual_minutes?: number | null + budget_money?: number | null + cost_estimate_money?: number | null } export class ApiError extends Error { @@ -109,6 +118,12 @@ }), redetailTask: (id: number) => request(`/api/tasks/${id}/redetail`, { method: 'POST' }), + predictTask: (id: number) => request(`/api/tasks/${id}/predict`, { method: 'POST' }), + suggestTasks: (availableMinutes: number) => + request('/api/tasks/suggest', { + method: 'POST', + body: JSON.stringify({ available_minutes: availableMinutes }), + }), deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), // attachments listAttachments: (taskId: number) => diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9a7b4bd..0cd0b7a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,6 +7,7 @@ import App from './App.vue' import ListView from './views/ListView.vue' +import OptionsView from './views/OptionsView.vue' import ProjectsView from './views/ProjectsView.vue' import StackView from './views/StackView.vue' import TreeView from './views/TreeView.vue' @@ -19,6 +20,7 @@ { path: '/tree', component: TreeView }, { path: '/list', component: ListView }, { path: '/projects', component: ProjectsView }, + { path: '/options', component: OptionsView }, ], }) diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index 62f1c7d..9680815 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -39,4 +39,14 @@ export function renderMarkdown(text: string): string { return DOMPurify.sanitize(marked.parse(text, { async: false })) +} + +// «90» → «1 ч 30 м»; для чипов оценок времени +export function formatMinutes(minutes: number | null): string { + if (minutes === null) return '' + const h = Math.floor(minutes / 60) + const m = minutes % 60 + if (h && m) return `${h} ч ${m} м` + if (h) return `${h} ч` + return `${m} м` } \ No newline at end of file diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue index f187fe1..98d3cea 100644 --- a/frontend/src/views/ListView.vue +++ b/frontend/src/views/ListView.vue @@ -1,7 +1,7 @@ + + + + \ No newline at end of file diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index 6b6d153..ff4e45d 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -3,6 +3,7 @@ import { marked } from 'marked' import { computed, onMounted, onUnmounted, ref } from 'vue' import { api, type Attachment, type Project, type Task } from '../api' +import { formatMinutes } from '../taskui' // Стек входящих: сырые задачи, ожидающие детализации и утверждения. // LLM-предложение — черновик: «Да, всё верно» применяет его, иначе правим вручную. @@ -21,6 +22,9 @@ project_id: '' as string | number, tagSelection: {} as Record, priority: null as number | null, + estimated_minutes: null as number | null, + budget_money: null as number | null, + cost_estimate_money: null as number | null, }) const showPreview = ref(false) const attachments = ref([]) @@ -95,6 +99,9 @@ project_id: task.project?.id ?? '', tagSelection: selection, priority: task.priority, + estimated_minutes: task.estimated_minutes, + budget_money: task.budget_money, + cost_estimate_money: task.cost_estimate_money, } showPreview.value = false void loadAttachments(task.id) @@ -118,6 +125,9 @@ project_id: editForm.value.project_id === '' ? null : Number(editForm.value.project_id), tag_ids: selectedTagIds(), priority: editForm.value.priority, + estimated_minutes: editForm.value.estimated_minutes, + budget_money: editForm.value.budget_money, + cost_estimate_money: editForm.value.cost_estimate_money, }) await api.approveTask(editing.value.id) editing.value = null @@ -224,6 +234,8 @@ p.tags.forEach((t) => items.push(t)) if (p.project) items.push(p.new_project ? `+ проект «${p.project}»` : p.project) if (p.priority !== null) items.push(`приоритет ${p.priority}`) + if (p.estimated_minutes !== null && p.estimated_minutes !== undefined) + items.push(`≈ ${formatMinutes(p.estimated_minutes)}`) return items } @@ -308,6 +320,26 @@ + +
+ + +
@@ -469,4 +501,11 @@ display: flex; gap: 0.75rem; } +.budget-row { + display: flex; + gap: 0.75rem; +} +.budget-row > * { + flex: 1; +} \ No newline at end of file diff --git a/frontend/src/views/TreeView.vue b/frontend/src/views/TreeView.vue index 73f9c68..979ad7c 100644 --- a/frontend/src/views/TreeView.vue +++ b/frontend/src/views/TreeView.vue @@ -1,7 +1,7 @@