diff --git a/src/components/AiProjectChat.vue b/src/components/AiProjectChat.vue index 321d260..2f3ea9b 100644 --- a/src/components/AiProjectChat.vue +++ b/src/components/AiProjectChat.vue @@ -3,6 +3,8 @@ import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' import { chatWithAI } from '@/services/ai' import { useSettingsStore, AVAILABLE_MODELS, PROVIDER_CONFIG, hasProviderApiKey, type AIProvider } from '@/stores/settings' +import { useWorkItemsStore } from '@/stores/workitems' +import { getSessionsByProject, getProjectState } from '@/services/transcriptions-db' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -18,8 +20,10 @@ import { Send, Loader2, AlertCircle, Brain, Settings2, Check, ChevronDown } from const { t } = useI18n() const settings = useSettingsStore() +const workItems = useWorkItemsStore() const props = defineProps({ + projectId: { type: Number, required: true }, projectName: { type: String, required: true }, projectDescription: { type: String, default: '' }, epicCount: { type: Number, required: true }, @@ -35,43 +39,38 @@ const response = ref('') const loading = ref(false) const error = ref(null) -const configuredProviders = computed(() => { - const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode'] - return providers.filter(p => hasProviderApiKey(p)) -}) - -const allProviders = computed(() => { - const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode'] - return providers -}) +const allProviders: AIProvider[] = ['openrouter', 'minimax', 'opencode'] function modelsForProvider(p: AIProvider) { return AVAILABLE_MODELS.filter(m => m.provider === p) } function switchModel(provider: AIProvider, modelId: string) { - if (provider !== settings.provider) { - settings.setActiveProvider(provider) - } + if (provider !== settings.provider) settings.setActiveProvider(provider) settings.setModel(modelId) } -function buildSystemPrompt(): string { - return `Eres un asistente de proyectos ágil integrado en ${props.projectName}. -Tu rol es ayudar al usuario a pensar, planificar y organizar el trabajo del proyecto. +async function buildContext(): Promise { + const ctx: Record = {} -Contexto del proyecto: -- Nombre: ${props.projectName} -- Descripción: ${props.projectDescription || 'Sin descripción'} -- Épicas: ${props.epicCount} -- HUs: ${props.huCount} + // 1. HUs: todas, formato compacto (sin límite) + const hus = workItems.userStories + if (hus.length > 0) { + const pending = hus.filter(h => !['done','completed','closed','finalizado'].includes(String(h.status ?? '').toLowerCase())) + ctx.hus = { total: hus.length, pending: pending.length, items: hus.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority })) } + } -Reglas: -1. Respondé en el mismo idioma del usuario (español por defecto) -2. Sé conciso y práctico -3. Si el usuario pide crear algo que requiere acción en KAPPA, explicá que debe usar la sección de Transcripciones -4. Podés ayudar a redactar HUs, criterios de aceptación, priorizar tareas, sugerir enfoques -5. No inventes información que no esté en el contexto del proyecto` + // 2. Sesiones: últimas 3 + const sessions = await getSessionsByProject(props.projectId) + if (sessions.length > 0) { + ctx.sessions = { total: sessions.length, recent: sessions.slice(-3).reverse().map(s => ({ d: s.date, t: s.title })) } + } + + // 3. Estado + const state = await getProjectState(props.projectId) + if (state?.summary) ctx.summary = state.summary.slice(0, 300) + + return JSON.stringify(ctx) } async function sendPrompt() { @@ -84,8 +83,13 @@ async function sendPrompt() { prompt.value = '' try { + const context = await buildContext() + const systemPrompt = `Proyecto: ${props.projectName}. ${props.projectDescription || ''} Épicas: ${props.epicCount} HUs: ${props.huCount}. +Datos del proyecto (JSON): ${context || '{}'} +Respondé en el mismo idioma del usuario. Sé conciso. Si no sabés, decilo.` + const msgs = [{ role: 'user' as const, content: text }] - const result = await chatWithAI(msgs, buildSystemPrompt()) + const result = await chatWithAI(msgs, systemPrompt) response.value = result } catch (e: any) { error.value = e.message @@ -131,16 +135,8 @@ async function sendPrompt() { >
{{ PROVIDER_CONFIG[p].label }} - {{ t('projectAi.keyReady') }} - {{ t('projectAi.noKey') }} + {{ t('projectAi.keyReady') }} + {{ t('projectAi.noKey') }}
- +
{{ m.label }} {{ m.id }} @@ -161,10 +154,7 @@ async function sendPrompt() { - + {{ t('projectAi.settings') }} @@ -194,10 +184,7 @@ async function sendPrompt() { class="text-xs text-muted-foreground p-3 rounded-lg border border-dashed text-center" > {{ t('projectAi.noKey') }} - . + .
@@ -220,9 +207,7 @@ async function sendPrompt() {
-

- Ctrl+Enter -

+

Ctrl+Enter

diff --git a/src/services/project-analyzer.ts b/src/services/project-analyzer.ts new file mode 100644 index 0000000..1ac6eea --- /dev/null +++ b/src/services/project-analyzer.ts @@ -0,0 +1,172 @@ +import { callAI } from '@/services/ai' +import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db' +import { kappa } from '@/services/kappa-api' +import type { EnrichedUserStory } from '@/stores/workitems' + +interface AnalysisHU { + title: string + description: string + acceptance_criteria: string[] + priority: string +} + +interface AnalysisResult { + hus: AnalysisHU[] + summary: string +} + +const ANALYSIS_SYSTEM_PROMPT = `Eres un analista funcional experto. Tu tarea es analizar TODO el contexto de un proyecto y generar las Historias de Usuario (HUs) que faltan. + +Reglas: +1. Analizá TODA la información disponible: sesiones, resúmenes, estado del proyecto, HUs existentes +2. Identificá requisitos, funcionalidades, mejoras o bugs que NO estén cubiertos por las HUs existentes +3. Cada HU debe tener: título claro, descripción detallada, criterios de aceptación verificables +4. No generes HUs duplicadas. Compará con la lista de HUs existentes +5. Priorizá según urgencia implícita (Alta/Media/Baja) +6. Si todo ya está cubierto, devolvé un arreglo vacío +7. Respondé SOLO con JSON válido + +Contexto recibido en JSON: +- existingHUs: HUs que ya existen en el proyecto (NO repetir) +- sessions: sesiones registradas con resúmenes +- projectState: estado consolidado del proyecto + +Formato de respuesta: +{ + "hus": [ + { + "title": "Título de la HU", + "description": "Descripción detallada", + "acceptance_criteria": ["Criterio 1", "Criterio 2"], + "priority": "Alta|Media|Baja" + } + ], + "summary": "Resumen del análisis: cuántas HUs se crearon y por qué" +}` + +export async function analyzeProject( + projectId: number, + projectName: string, + existingHUs: EnrichedUserStory[], + signal?: AbortSignal, +): Promise { + // 1. Recolectar todo el contexto del proyecto + const sessions = await getSessionsByProject(projectId) + const state = await getProjectState(projectId) + + // 2. Construir context compacto en JSON + const sessionsWithSummaries = [] + for (const s of sessions) { + const summary = await getSessionSummary(s.id!) + sessionsWithSummaries.push({ + date: s.date, + title: s.title, + file: s.fileName, + summary: summary?.summary?.slice(0, 500) || '', + tasks: (safeParse<{ description: string; priority: string }[]>(summary?.tasks, [])).map(t => ({ d: t.description, p: t.priority })), + decisions: safeParse(summary?.decisions, []), + }) + } + + const context = { + projectName, + existingHUs: existingHUs.map(h => ({ + t: h._cleanTitle || h.title, + s: h.status, + p: h.priority, + })), + sessions: sessionsWithSummaries.slice(-10).reverse(), + projectState: state ? { + summary: state.summary?.slice(0, 500), + objectives: safeParse(state.objectives, []), + tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30), + } : null, + } + + const userContent = `Contexto completo del proyecto en JSON:\n${JSON.stringify(context, null, 0)}` + + console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`) + + const content = await callAI( + [ + { role: 'system', content: ANALYSIS_SYSTEM_PROMPT }, + { role: 'user', content: userContent }, + ], + 0.3, + 8192, + signal, + ) + + try { + const jsonStr = extractJSON(content) + const result: AnalysisResult = JSON.parse(jsonStr) + console.log(`[Alpha] Analysis result: ${result.hus.length} nuevas HUs propuestas`) + return result + } catch (e) { + console.error('[Alpha] Failed to parse analysis. Raw:', content) + throw new Error('No se pudo procesar el análisis del proyecto') + } +} + +/** + * Crea las HUs propuestas en KAPPA (solo las que no existen). + * Devuelve cuántas se crearon. + */ +export async function createMissingHUs( + projectId: number, + analysis: AnalysisResult, + existingHUs: EnrichedUserStory[], +): Promise<{ created: number; skipped: number; errors: string[] }> { + let created = 0 + let skipped = 0 + const errors: string[] = [] + + const existingTitles = new Set(existingHUs.map(h => (h._cleanTitle || h.title).toLowerCase().trim())) + + for (const hu of analysis.hus) { + const normalizedTitle = hu.title.toLowerCase().trim() + + // Verificar duplicado por título similar + const isDuplicate = existingHUs.some(ex => { + const et = (ex._cleanTitle || ex.title).toLowerCase().trim() + return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et) + }) + + if (isDuplicate) { + skipped++ + continue + } + + try { + await kappa.createUserStory({ + title: hu.title, + description: hu.description + (hu.acceptance_criteria.length > 0 + ? `\n\n**Criterios de aceptación:**\n${hu.acceptance_criteria.map(c => `- ${c}`).join('\n')}` + : ''), + initiative: projectId, + priority: hu.priority || 'Media', + status: 'todo', + }) + created++ + } catch (e: any) { + errors.push(`${hu.title}: ${e.message}`) + } + } + + return { created, skipped, errors } +} + +function extractJSON(text: string): string { + try { JSON.parse(text); return text } catch {} + const block = text.match(/```(?:json)?\s*([\s\S]*?)```/) + if (block) { try { JSON.parse(block[1].trim()); return block[1].trim() } catch {} } + const first = text.indexOf('{') + const last = text.lastIndexOf('}') + if (first !== -1 && last > first) { const c = text.slice(first, last + 1); try { JSON.parse(c); return c } catch {} } + return text +} + +function safeParse(json: string | undefined | null, fallback: T): T { + if (!json) return fallback + try { return JSON.parse(json) as T } catch { return fallback } +} diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index e8a38fb..fe1a70a 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -1,12 +1,14 @@