Newer
Older
gnexus-tasks / frontend / src / aiPrompt.ts
// Промпт для ИИ-агента (0.87): владелец копирует текст задачи с её страницы и
// отдаёт своему агенту. Внутри — только факты о задаче и ссылка на неё: указаний
// «что делать» здесь нет, это дело владельца, а не кнопки. Собирается из того же
// источника, что и карточки страницы, поэтому расхождений с интерфейсом не будет:
// подписи полей — из локали, значения — теми же форматтерами, что в taskui.
import type { Task } from './api'
import {
  deadlineLabel,
  formatMinutes,
  formatMoney,
  priorityLabel,
  recurrenceLabel,
  statusLabel,
} from './taskui'

/** Функция перевода vue-i18n (нужна только именованная подстановка). */
type Translate = (key: string, named?: Record<string, unknown>) => string

export interface PromptContext {
  /** Подзадачи задачи — в промпт идут заголовками и статусами */
  subtasks?: Task[]
  /** Родительская задача: агент должен видеть, частью чего является задача */
  parent?: Task | null
  /** Готовая строка аренды («Взято Нави · до 14:30»), если задачу уже держит агент */
  lease?: string
  /** Ссылка на страницу задачи в интерфейсе */
  url: string
}

export function buildTaskPrompt(
  task: Task,
  context: PromptContext,
  t: Translate,
): string {
  const lines: string[] = []
  const add = (line = '') => lines.push(line)
  const field = (label: string, value: unknown) => {
    if (value === undefined || value === null || value === '') return
    add(`- ${label}: ${value}`)
  }

  add(t('task.ai.intro'))
  add()

  add(`## ${t('task.ai.taskSection')}`)
  field(t('task.ai.id'), `#${task.id}`)
  field(t('task.ai.titleField'), task.title)
  field(t('task.ai.status'), statusLabel(task.status))
  field(
    t('task.field.taskType'),
    task.task_type === 'recurring'
      ? recurrenceLabel(task) || t('stack.recur.recurring')
      : t('stack.recur.oneTime'),
  )
  field(t('task.field.priority'), task.priority === null ? null : priorityLabel(task.priority))
  field(
    t('task.field.estimate'),
    task.estimated_minutes ? formatMinutes(task.estimated_minutes) : null,
  )
  field(t('task.field.actual'), task.actual_minutes ? formatMinutes(task.actual_minutes) : null)
  field(t('task.field.money'), moneyLine(task, t))
  field(t('task.field.deadline'), deadlineLabel(task))
  field(t('task.field.project'), task.project ? task.project.name : null)
  field(t('task.ai.tags'), task.tags.map((tag) => tag.name).join(', '))
  field(
    t('task.ai.parentTask'),
    context.parent ? `#${context.parent.id} ${context.parent.title}` : null,
  )
  field(t('task.ai.eligible'), task.ai_eligible ? t('task.ai.eligibleYes') : t('task.ai.eligibleNo'))
  field(t('task.ai.claimed'), context.lease)
  add()

  add(`## ${t('task.ai.descriptionSection')}`)
  add(task.description.trim() || t('task.ai.descriptionEmpty'))
  add()

  // Черновик автодетализации: задача ещё не утверждена, но теги, приоритет и
  // оценка времени в предложении уже есть — агенту это полезнее пустых полей выше
  const proposal = task.ai_proposal
  if (proposal) {
    const rows: string[] = []
    if (proposal.tags.length) rows.push(`- ${t('stack.form.tags')}: ${proposal.tags.join(', ')}`)
    if (proposal.priority != null)
      rows.push(`- ${t('task.field.priority')}: ${priorityLabel(proposal.priority)}`)
    if (proposal.estimated_minutes != null)
      rows.push(`- ${t('task.field.estimate')}: ${formatMinutes(proposal.estimated_minutes)}`)
    if (rows.length) {
      add(`## ${t('task.ai.proposalSection')}`)
      rows.forEach(add)
      add()
    }
  }

  const subtasks = context.subtasks ?? []
  if (subtasks.length) {
    add(`## ${t('task.ai.subtasksSection')}`)
    subtasks.forEach((child) => add(`- #${child.id} ${child.title} — ${statusLabel(child.status)}`))
    add()
  }

  add(`## ${t('task.ai.linkSection')}`)
  add(context.url)
  return lines.join('\n')
}

/** «бюджет 5 000 ₴ · оценка 3 000 ₴» — как в сайдбаре параметров страницы. */
function moneyLine(task: Task, t: Translate): string {
  return [
    task.budget_money !== null
      ? t('taskui.money.budget', { amount: formatMoney(task.budget_money) })
      : '',
    task.cost_estimate_money !== null
      ? t('taskui.money.estimate', { amount: formatMoney(task.cost_estimate_money) })
      : '',
  ]
    .filter(Boolean)
    .join(' · ')
}