diff --git a/backend/alembic/versions/b0dc582ec93e_m2_ai_proposal_attachments.py b/backend/alembic/versions/b0dc582ec93e_m2_ai_proposal_attachments.py new file mode 100644 index 0000000..7e0f4dd --- /dev/null +++ b/backend/alembic/versions/b0dc582ec93e_m2_ai_proposal_attachments.py @@ -0,0 +1,44 @@ +"""M2: ai_proposal, attachments + +Revision ID: b0dc582ec93e +Revises: 9886818da1f0 +Create Date: 2026-09-19 20:42:27.973801 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'b0dc582ec93e' +down_revision: str | Sequence[str] | None = '9886818da1f0' +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.create_table('attachments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task_id', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('original_name', sa.String(length=255), nullable=False), + sa.Column('mime', sa.String(length=100), nullable=False), + sa.Column('size', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('tasks', sa.Column('ai_proposal', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tasks', 'ai_proposal') + op.drop_table('attachments') + # ### end Alembic commands ### diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py new file mode 100644 index 0000000..8f0806c --- /dev/null +++ b/backend/app/api/attachments.py @@ -0,0 +1,99 @@ +"""API вложений M2: изображения задач, в том числе вставка из буфера обмена.""" + +import shutil +import uuid +from pathlib import Path +from typing import Any, cast + +from fastapi import APIRouter, HTTPException, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy import select + +from app.config import get_settings +from app.dependencies import DbDep, UserDep +from app.models import Attachment, Task +from app.schemas import AttachmentOut + +router = APIRouter(prefix="/api", tags=["attachments"]) + +ALLOWED_PREFIXES = ("image/",) + + +def _attachments_dir() -> Path: + path = Path(get_settings().attachments_path) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _get_task_or_404(db: Any, task_id: int) -> Task: + task = cast(Task | None, db.get(Task, task_id)) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return task + + +def _get_attachment_or_404(db: Any, attachment_id: int) -> Attachment: + att = cast(Attachment | None, db.get(Attachment, attachment_id)) + if att is None: + raise HTTPException(status_code=404, detail="Attachment not found") + return att + + +@router.post("/tasks/{task_id}/attachments", response_model=list[AttachmentOut]) +async def upload_attachments( + task_id: int, db: DbDep, user: UserDep, files: list[UploadFile] +) -> list[Attachment]: + """Загрузка файлов (в т.ч. Ctrl+V из буфера — приходит как обычный файл).""" + _get_task_or_404(db, task_id) + saved: list[Attachment] = [] + for file in files: + mime = file.content_type or "application/octet-stream" + if not mime.startswith(ALLOWED_PREFIXES): + raise HTTPException(status_code=415, detail=f"Unsupported type: {mime}") + ext = Path(file.filename or "image").suffix or ".bin" + stored_name = f"{uuid.uuid4().hex}{ext}" + target = _attachments_dir() / stored_name + with target.open("wb") as out: + shutil.copyfileobj(file.file, out) + att = Attachment( + task_id=task_id, + filename=stored_name, + original_name=(file.filename or "image")[:255], + mime=mime, + size=target.stat().st_size, + ) + db.add(att) + saved.append(att) + db.flush() + return saved + + +@router.get("/tasks/{task_id}/attachments", response_model=list[AttachmentOut]) +async def list_attachments(task_id: int, db: DbDep, user: UserDep) -> list[Attachment]: + _get_task_or_404(db, task_id) + return list( + db.scalars( + select(Attachment).where(Attachment.task_id == task_id).order_by(Attachment.id) + ).all() + ) + + +@router.get("/attachments/{attachment_id}/file") +async def get_attachment_file( + attachment_id: int, db: DbDep, user: UserDep +) -> FileResponse: + att = _get_attachment_or_404(db, attachment_id) + path = _attachments_dir() / att.filename + if not path.is_file(): + raise HTTPException(status_code=404, detail="File missing on disk") + return FileResponse(path, media_type=att.mime) + + +@router.delete("/attachments/{attachment_id}") +async def delete_attachment(attachment_id: int, db: DbDep, user: UserDep) -> dict[str, bool]: + att = _get_attachment_or_404(db, attachment_id) + path = _attachments_dir() / att.filename + if path.is_file(): + path.unlink() + db.delete(att) + return {"ok": True} diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index e88f3b1..ef24453 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,13 +1,14 @@ -"""API задач M1: быстрый захват, стек, ручная детализация, CRUD.""" +"""API задач M1/M2: быстрый захват, стек, детализация (LLM-предложение), CRUD.""" from typing import Any, cast -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from sqlalchemy import select from app.dependencies import DbDep, UserDep from app.models import Project, Tag, Task, utcnow -from app.schemas import TaskCreate, TaskOut, TaskUpdate +from app.schemas import ApproveIn, TaskCreate, TaskOut, TaskUpdate +from app.services.detailing import apply_proposal, detail_task router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -22,11 +23,20 @@ @router.post("") -async def create_task(schema: TaskCreate, db: DbDep, user: UserDep) -> dict[str, int]: - """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do).""" +async def create_task( + schema: TaskCreate, db: DbDep, user: UserDep, background: BackgroundTasks +) -> dict[str, int]: + """Быстрый захват: достаточно title — задача попадает в стек (raw, to_do). + + Сразу в фоне запускается автодетализация (LLM-предложение метаданных). + """ task = Task(title=schema.title, description=schema.description) db.add(task) db.flush() + # Коммитим до планирования фоновой работы: BackgroundTasks выполняются ДО + # teardown-коммита зависимости get_db, а detail_task открывает свою сессию. + db.commit() + background.add_task(detail_task, task.id) return {"id": task.id} @@ -83,11 +93,36 @@ @router.post("/{task_id}/approve", response_model=TaskOut) -async def approve_task(task_id: int, db: DbDep, user: UserDep) -> Task: - """Утверждение детализации: raw → approved.""" +async def approve_task( + task_id: int, db: DbDep, user: UserDep, schema: ApproveIn | None = None +) -> Task: + """Утверждение детализации: raw → approved. + + schema.apply_proposal=True — принять предложение автодетализации перед + утверждением (кнопка «Да, всё верно»); иначе предложенные метаданные + игнорируются (после ручной правки их уже применил PATCH). + """ task = _get_task_or_404(db, task_id) + if schema and schema.apply_proposal and task.ai_proposal: + apply_proposal(db, task, task.ai_proposal) task.detail_state = "approved" task.approved_at = utcnow() + db.flush() + db.refresh(task) + return task + + +@router.post("/{task_id}/redetail", response_model=TaskOut) +async def redetail_task( + task_id: int, db: DbDep, user: UserDep, background: BackgroundTasks +) -> Task: + """Перезапустить автодетализацию (например, после появления новых тегов/проектов).""" + task = _get_task_or_404(db, task_id) + task.ai_proposal = None + # Коммит до фоновой работы, чтобы worker не прочитал старое состояние + # и его запись не перезаписалась teardown-коммитом (см. create_task). + db.commit() + background.add_task(detail_task, task.id) return task diff --git a/backend/app/config.py b/backend/app/config.py index 4b452ac..bea2a54 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,7 +23,10 @@ # Ollama (M2) ollama_base_url: str = "http://localhost:11434" - ollama_model: str = "qwen2.5:3b" + ollama_model: str = "gemma4:e2b-it-qat" + + # Хранилище вложений (M2) + attachments_path: str = "data/attachments" @lru_cache diff --git a/backend/app/main.py b/backend/app/main.py index 43efc48..b971fb5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware -from app.api import projects, tags, tasks +from app.api import attachments, projects, tags, tasks from app.auth.routes import router as auth_router from app.config import get_settings @@ -26,6 +26,7 @@ app.include_router(tasks.router) app.include_router(projects.router) app.include_router(tags.router) +app.include_router(attachments.router) @app.get("/api/health") diff --git a/backend/app/models.py b/backend/app/models.py index 567e1a8..ae06ffe 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,8 +5,9 @@ """ from datetime import UTC, datetime +from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base @@ -45,6 +46,10 @@ ) project: Mapped["Project | None"] = relationship(back_populates="tasks", lazy="joined") + # Черновое предложение автодетализации (LLM): теги/проект/приоритет. + # Не окончательные метаданные — применяется только при утверждении. + ai_proposal: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) approved_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None @@ -89,3 +94,15 @@ tag_id: Mapped[int] = mapped_column( ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True ) + + +class Attachment(Base): + __tablename__ = "attachments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + task_id: Mapped[int] = mapped_column(ForeignKey("tasks.id", ondelete="CASCADE")) + filename: Mapped[str] = mapped_column(String(255)) # имя на диске (uuid) + original_name: Mapped[str] = mapped_column(String(255)) # имя при загрузке + mime: Mapped[str] = mapped_column(String(100)) + size: Mapped[int] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2287fd1..ac930e1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,6 +1,7 @@ """Pydantic-схемы API M1.""" from datetime import datetime +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -53,14 +54,35 @@ project: ProjectOut | None priority: int | None tags: list[TagOut] + ai_proposal: dict[str, Any] | None created_at: datetime approved_at: datetime | None done_at: datetime | None +class ApproveIn(BaseModel): + """Утверждение детализации; apply_proposal=True — принять предложение LLM.""" + + apply_proposal: bool = False + + # --- Project / Tag --- +class AttachmentOut(BaseModel): + """Вложение задачи (изображение).""" + + model_config = ConfigDict(from_attributes=True) + + id: int + task_id: int + filename: str + original_name: str + mime: str + size: int + created_at: datetime + + class ProjectCreate(BaseModel): name: str = Field(min_length=1, max_length=200) priority: int | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/backend/app/services/__init__.py diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py new file mode 100644 index 0000000..784fbca --- /dev/null +++ b/backend/app/services/detailing.py @@ -0,0 +1,140 @@ +"""Автодетализация задач: маленькая LLM (Ollama) предлагает черновые метаданные. + +Предложение — НЕ окончательная детализация (см. docs/TZ.md 3.2/3.10): оно +применяется к задаче только при утверждении пользователем («Да, всё верно» +или после ручной правки). Ожидаемы промахи — это нормально. +""" + +import json +import logging +from typing import Any + +import httpx +from sqlalchemy import select + +from app.config import get_settings +from app.db import get_session_factory +from app.models import Project, Tag, Task + +logger = logging.getLogger(__name__) + +MAX_TAGS = 3 +PRIORITY_SCALE = "10 — срочно и важно; 7 — важно; 4 — обычное; 1 — когда-нибудь; null — неясно" + + +def build_prompt( + title: str, description: str, tag_names: list[str], project_names: list[str] +) -> str: + return ( + "Ты — ассистент личного таск-менеджера. Подбери черновые метаданные для новой задачи.\n\n" + f"Доступные теги (выбирай ТОЛЬКО из них, максимум {MAX_TAGS}): " + f"{json.dumps(tag_names, ensure_ascii=False) or '[]'}\n" + "Доступные проекты: " + f"{json.dumps(project_names, ensure_ascii=False) or '[]'} — " + "или предложи краткое название нового проекта, если ни один не подходит.\n\n" + f'Задача: "{title}"\n' + f'Описание: "{description}"\n\n' + f"Шкала приоритета: {PRIORITY_SCALE}.\n\n" + 'Ответь ТОЛЬКО JSON вида: {"tags": ["тег", ...], "project": "имя или null", ' + '"new_project": true или false, "priority": число или null}' + ) + + +class DetailingService: + """Вызов LLM и разбор ответа. generate() изолирован для тестов.""" + + def __init__(self, base_url: str | None = None, model: str | None = None) -> None: + settings = get_settings() + self.base_url = (base_url or settings.ollama_base_url).rstrip("/") + self.model = model or settings.ollama_model + + def generate(self, prompt: str) -> str: + response = httpx.post( + f"{self.base_url}/api/generate", + json={"model": self.model, "prompt": prompt, "format": "json", "stream": False}, + timeout=180.0, + ) + response.raise_for_status() + return str(response.json()["response"]) + + def propose( + self, title: str, description: str, tag_names: list[str], project_names: list[str] + ) -> dict[str, Any] | None: + prompt = build_prompt(title, description, tag_names, project_names) + try: + raw = self.generate(prompt) + data = json.loads(raw) + except Exception: + logger.warning("Detailing LLM call failed for task %r", title, exc_info=True) + return None + + if not isinstance(data, dict): + return None + + known_tags = set(t.lower() for t in tag_names) + tags = [t for t in data.get("tags") or [] if isinstance(t, str) and t.lower() in known_tags] + tags = tags[:MAX_TAGS] + + priority = data.get("priority") + if priority is not None and not (isinstance(priority, int) and 0 <= priority <= 10): + priority = None + + project = data.get("project") + if not isinstance(project, str) or not project.strip(): + project = None + + return { + "tags": tags, + "project": project, + "new_project": bool(data.get("new_project")) or project not in project_names, + "priority": priority, + } + + +def apply_proposal(db: Any, task: Task, proposal: dict[str, Any]) -> None: + """Применить предложение к задаче: теги (только существующие), проект, приоритет.""" + tag_names = proposal.get("tags") or [] + if tag_names: + tags = [ + t + for t in db.scalars(select(Tag)).all() + if t.name.lower() in {n.lower() for n in tag_names} + ] + task.tags = list(tags) + + project_name = proposal.get("project") + if project_name: + project = db.scalars( + select(Project).where(Project.name.ilike(project_name)) + ).first() + if project is None and proposal.get("new_project"): + project = Project(name=project_name.strip()) + db.add(project) + db.flush() + if project is not None: + task.project_id = project.id + + if proposal.get("priority") is not None: + task.priority = proposal["priority"] + + +def detail_task(task_id: int) -> None: + """Фоновая работа: сгенерировать и сохранить предложение для задачи в стеке.""" + session = get_session_factory()() + try: + task = session.get(Task, task_id) + if task is None or task.detail_state != "raw": + return + + service = DetailingService() + proposal = service.propose( + task.title, + task.description, + [t.name for t in session.scalars(select(Tag)).all()], + [p.name for p in session.scalars(select(Project)).all()], + ) + if proposal is not None: + task.ai_proposal = proposal + session.commit() + finally: + session.close() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ec32fa3..f675982 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ "httpx>=0.24", "gnexus-gauth", "itsdangerous>=2.2.0", + "python-multipart>=0.0.32", ] [dependency-groups] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/backend/tests/__init__.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c0985a5..e1ca196 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,5 +1,7 @@ """Фикстуры тестов: in-memory БД и авторизация-заглушка.""" +import os +import tempfile from collections.abc import Iterator from typing import Any @@ -9,10 +11,13 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app import services from app.db import Base from app.dependencies import get_db, require_user from app.main import app +detailing = services.detailing + TEST_DB_URL = "sqlite://" _engine = create_engine( @@ -41,17 +46,37 @@ AUTH_USER: dict[str, str] = {"user_id": "1", "email": "test@example.com"} app.dependency_overrides[require_user] = lambda: AUTH_USER +# Вложения в тестах — во временный каталог +_tmpdir = tempfile.mkdtemp(prefix="gntodo-att-") +os.environ.setdefault("ATTACHMENTS_PATH", _tmpdir) + + +@pytest.fixture(autouse=True) +def _mock_detailing(monkeypatch: Any) -> Iterator[None]: + """Фоновая детализация в тестах: тестовая БД и без реального вызова Ollama.""" + monkeypatch.setattr(detailing, "get_session_factory", lambda: _test_session_factory) + # Запрещаем сетевой вызов LLM: propose вернёт None (как при недоступной Ollama). + # Тесты при необходимости подменяют propose/generate поверх этого. + monkeypatch.setattr( + detailing.DetailingService, + "generate", + lambda self, prompt: (_ for _ in ()).throw(RuntimeError("LLM отключён в тестах")), + ) + + +@pytest.fixture(autouse=True) +def _isolate_db() -> Iterator[None]: + """Чистые таблицы перед каждым тестом (in-memory БД общая на процесс).""" + conn = _engine.connect() + trans = conn.begin() + for table in reversed(Base.metadata.sorted_tables): + conn.execute(table.delete()) + trans.commit() + conn.close() + yield + @pytest.fixture def client() -> Iterator[TestClient]: with TestClient(app) as c: yield c - - -def create_task(client: TestClient, title: str = "тестовая задача") -> dict[str, Any]: - resp = client.post("/api/tasks", json={"title": title}) - assert resp.status_code == 200, resp.text - task_id = resp.json()["id"] - resp = client.get(f"/api/tasks/{task_id}") - assert resp.status_code == 200, resp.text - return resp.json() diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py new file mode 100644 index 0000000..4e62b87 --- /dev/null +++ b/backend/tests/test_detailing.py @@ -0,0 +1,164 @@ +"""Тесты M2: автодетализация (LLM-предложение) и вложения.""" + +from typing import Any + +from fastapi.testclient import TestClient + +from app import services +from app.services.detailing import apply_proposal, build_prompt + + +def _mock_proposal(monkeypatch: Any, proposal: dict[str, Any]) -> None: + """Подменить LLM-вызов фиксированным ответом.""" + + def fake_propose( + self: Any, title: str, description: str, tag_names: list[str], project_names: list[str] + ) -> dict[str, Any]: + return proposal + + monkeypatch.setattr(services.detailing.DetailingService, "propose", fake_propose) + + +def create_task(client: TestClient, title: str = "тестовая задача") -> dict[str, Any]: + resp = client.post("/api/tasks", json={"title": title}) + assert resp.status_code == 200, resp.text + return client.get(f"/api/tasks/{resp.json()['id']}").json() + + +def set_proposal(task_id: int, proposal: dict[str, Any]) -> None: + """Записать LLM-предложение в задачу напрямую (как сделал бы background worker).""" + from app.models import Task + from tests.conftest import _test_session_factory + + session = _test_session_factory() + task = session.get(Task, task_id) + assert task is not None + task.ai_proposal = proposal + session.commit() + session.close() + + +def test_build_prompt_lists_catalog() -> None: + prompt = build_prompt("задача", "", ["быт", "дом"], ["ремонт"]) + assert "быт" in prompt and "ремонт" in prompt + assert "задача" in prompt + assert "new_project" in prompt + + +def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None: + proposal = {"tags": [], "project": None, "new_project": False, "priority": 4} + _mock_proposal(monkeypatch, proposal) + + # BackgroundTasks выполняются TestClient синхронно после ответа + resp = client.post("/api/tasks", json={"title": "помыть посуду"}) + assert resp.status_code == 200 + task = client.get(f"/api/tasks/{resp.json()['id']}").json() + assert task["ai_proposal"] == proposal + + +def test_propose_filters_unknown_tags_and_priority(client: TestClient, monkeypatch: Any) -> None: + service = services.detailing.DetailingService(base_url="http://mock", model="m") + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"tags": ["быт", "несуществующий"], "project": "Дом", ' + '"new_project": false, "priority": 99}', + ) + proposal = service.propose("задача", "", ["быт"], ["Дом"]) + assert proposal is not None + assert proposal["tags"] == ["быт"] # неизвестный тег отфильтрован + assert proposal["priority"] is None # 99 вне шкалы 0–10 + + +def test_apply_proposal_maps_and_creates(client: TestClient) -> None: + client.post("/api/tags", json={"name": "быт"}) + client.post("/api/projects", json={"name": "Дом"}) + + proposal = {"tags": ["БЫТ"], "project": "Дом", "new_project": False, "priority": 7} + resp = client.post("/api/tasks", json={"title": "задача с предложением"}) + task_id = resp.json()["id"] + set_proposal(task_id, proposal) + + approved = client.post( + f"/api/tasks/{task_id}/approve", json={"apply_proposal": True} + ).json() + assert approved["detail_state"] == "approved" + assert [t["name"] for t in approved["tags"]] == ["быт"] # case-insensitive матч + assert approved["project"]["name"] == "Дом" + assert approved["priority"] == 7 + + +def test_apply_proposal_creates_new_project(client: TestClient) -> None: + resp = client.post("/api/tasks", json={"title": "новое направление"}) + task_id = resp.json()["id"] + proposal = {"tags": [], "project": "Ремонт дачи", "new_project": True, "priority": None} + set_proposal(task_id, proposal) + + approved = client.post( + f"/api/tasks/{task_id}/approve", json={"apply_proposal": True} + ).json() + assert approved["project"]["name"] == "Ремонт дачи" + # Проект создан в справочнике + names = [p["name"] for p in client.get("/api/projects").json()] + assert "Ремонт дачи" in names + + +def test_apply_proposal_function_directly(client: TestClient) -> None: + """apply_proposal игнорирует неизвестные теги.""" + proposal = {"tags": ["нет такого"], "project": None, "new_project": False, "priority": 3} + task = create_task(client) + from app.models import Task + from tests.conftest import _test_session_factory + + session = _test_session_factory() + db_task = session.get(Task, task["id"]) + assert db_task is not None + apply_proposal(session, db_task, proposal) + assert db_task.priority == 3 + assert db_task.tags == [] + session.close() + + +def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None: + _mock_proposal(monkeypatch, {"tags": [], "project": None, "new_project": False, "priority": 1}) + task = create_task(client) + assert task["ai_proposal"] is not None + + redetailed = client.post(f"/api/tasks/{task['id']}/redetail").json() + # фон выполнится после ответа; в ответе предложение уже сброшено + assert redetailed["ai_proposal"] is None + after = client.get(f"/api/tasks/{task['id']}").json() + assert after["ai_proposal"] is not None + + +def test_attachments_upload_list_fetch_delete(client: TestClient) -> None: + task = create_task(client) + png = b"\x89PNG\r\n\x1a\nfake-image-bytes" + + up = client.post( + f"/api/tasks/{task['id']}/attachments", + files={"files": ("скрин.png", png, "image/png")}, + ) + assert up.status_code == 200, up.text + att = up.json()[0] + assert att["original_name"] == "скрин.png" + assert att["mime"] == "image/png" + + lst = client.get(f"/api/tasks/{task['id']}/attachments").json() + assert len(lst) == 1 + + got = client.get(f"/api/attachments/{att['id']}/file") + assert got.status_code == 200 + assert got.content == png + + assert client.delete(f"/api/attachments/{att['id']}").json() == {"ok": True} + assert client.get(f"/api/attachments/{att['id']}/file").status_code == 404 + + +def test_attachments_reject_non_image(client: TestClient) -> None: + task = create_task(client) + up = client.post( + f"/api/tasks/{task['id']}/attachments", + files={"files": ("doc.pdf", b"%PDF-1.4", "application/pdf")}, + ) + assert up.status_code == 415 diff --git a/backend/uv.lock b/backend/uv.lock index ab38012..207cc5d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -277,6 +277,7 @@ { name = "itsdangerous" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -298,6 +299,7 @@ { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.6" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, ] @@ -953,6 +955,15 @@ ] [[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f7a9d0..0762e81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,9 @@ "name": "gntodo-frontend", "version": "0.1.0", "dependencies": { + "dompurify": "^3.4.15", "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", + "marked": "^18.0.13", "vue": "^3.5.0", "vue-router": "^4.5.0" }, @@ -929,6 +931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -1149,6 +1158,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1274,6 +1292,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index f63d2e8..81a718c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.4.15", "gnexus-ui-kit": "git+https://git.gnexus.space/git/root/gnexus-ui-kit.git", + "marked": "^18.0.13", "vue": "^3.5.0", "vue-router": "^4.5.0" }, @@ -19,4 +21,4 @@ "vite": "^6.0.0", "vue-tsc": "^2.2.0" } -} \ No newline at end of file +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 10704f2..32b8a0b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -13,6 +13,23 @@ note: string } +// Черновое предложение автодетализации (LLM) +export interface AiProposal { + tags: string[] + project: string | null + new_project: boolean + priority: number | null +} + +export interface Attachment { + id: number + task_id: number + original_name: string + mime: string + size: number + created_at: string +} + export interface Task { id: number title: string @@ -24,6 +41,7 @@ project: Project | null priority: number | null tags: Tag[] + ai_proposal: AiProposal | null created_at: string approved_at: string | null done_at: string | null @@ -47,10 +65,10 @@ } async function request(path: string, options: RequestInit = {}): Promise { - const res = await fetch(path, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }) + // Content-Type не задаётся для FormData — браузер проставит boundary сам. + const headers: Record = {} + if (!(options.body instanceof FormData)) headers['Content-Type'] = 'application/json' + const res = await fetch(path, { headers, ...options }) if (res.status === 401) { window.location.href = '/auth/login' throw new ApiError(401, 'Not authenticated') @@ -76,8 +94,29 @@ }), updateTask: (id: number, patch: TaskUpdateInput) => request(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), - approveTask: (id: number) => request(`/api/tasks/${id}/approve`, { method: 'POST' }), + approveTask: (id: number, applyProposal = false) => + request(`/api/tasks/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ apply_proposal: applyProposal }), + }), + redetailTask: (id: number) => + request(`/api/tasks/${id}/redetail`, { method: 'POST' }), deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }), + // attachments + listAttachments: (taskId: number) => + request(`/api/tasks/${taskId}/attachments`), + uploadAttachments: (taskId: number, files: File[]) => { + const form = new FormData() + files.forEach((f) => form.append('files', f)) + return request(`/api/tasks/${taskId}/attachments`, { + method: 'POST', + body: form, + }) + }, + deleteAttachment: (id: number) => + request<{ ok: boolean }>(`/api/attachments/${id}`, { method: 'DELETE' }), + // Ссылка на файл вложения (нужна сессионная cookie — просто ) + attachmentUrl: (id: number) => `/api/attachments/${id}/file`, // projects listProjects: () => request('/api/projects'), createProject: (name: string) => diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index 6e8f27f..ab6db55 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -1,8 +1,11 @@ @@ -248,6 +398,13 @@ color: var(--text-muted, #888); font-size: 0.85em; } +.proposal { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin-bottom: 0.75rem; +} .task-actions { display: flex; gap: 0.5rem; @@ -275,6 +432,37 @@ font-size: 0.85em; color: var(--text-muted, #888); } +.md-preview { + border: 1px solid var(--border, #333); + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; +} +.attachments { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.thumbs { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +.thumb { + position: relative; +} +.thumb img { + max-width: 120px; + max-height: 90px; + border-radius: 6px; + border: 1px solid var(--border, #333); + display: block; +} +.thumb .gn-icon-button { + position: absolute; + top: 4px; + right: 4px; +} .form-actions { display: flex; gap: 0.75rem;