K-10 pipeline transcripciones + settings IA + cache-aside + session doc

Nuevos modulos:
- services/ai.ts: cliente IA provider-agnostico (OpenRouter, MiniMax)
- services/db.ts: Dexie core con tabla settings + project_docs
- services/storage.ts: Cache-Aside + Write-Through (L1 Map → L2 Dexie → L3 localStorage)
- services/parse-transcription.ts: parser .docx/.vtt/.txt/.md
- services/session-analyzer.ts: extraccion IA de sesiones (resumen, tareas, decisiones)
- services/project-doc.ts: documento maestro MD (Bloque 1 resumen + Bloque 2 sesiones)
- stores/settings.ts: proveedores IA, modelos, API keys separadas por provider
- stores/transcriptions.ts: pipeline upload → analyze → create HU en KAPPA
- views/SettingsView.vue: configuracion IA (OpenRouter, MiniMax, OpenCode bridge)
- views/TranscriptionsView.vue: subida multiple + analisis sesion + visor MD + calendario
- components/AiProjectChat.vue: chat contextual por proyecto con selector de modelo

Cambios en existentes:
- stores/auth.ts, kappa-api.ts, upload-hu.ts: migrados a storage service (Dexie + localStorage)
- stores/projects.ts, workitems.ts: kappa_last_project via storage
- DashboardView.vue: descripcion reemplazada por AiProjectChat
- NewDashboardView.vue: tabs transcriptions + settings + navigate-settings events
- NavMain.vue: items Transcripciones + Configuracion
- SiteHeader.vue: labels tabs + language via storage
- LoginView.vue: remember_email via storage
- i18n: +80 keys español/ingles
- vite.config.ts: proxy CORS para MiniMax
- package.json: +mammoth.js
This commit is contained in:
2026-05-28 12:42:30 -05:00
parent 96ed01d922
commit 7d299554bf
28 changed files with 2426 additions and 56 deletions
+178
View File
@@ -0,0 +1,178 @@
import { PROVIDER_CONFIG, getProviderApiKey, type AIProvider } from '@/stores/settings'
import { storage } from '@/services/storage'
export interface AIExtractedHU {
title: string
description: string
acceptance_criteria: string[]
priority?: string
story_points?: number
type?: 'feature' | 'bug' | 'task' | 'improvement'
}
export interface AIAnalysisResult {
hus: AIExtractedHU[]
summary: string
}
interface ActiveConfig {
baseUrl: string
apiKey: string
model: string
provider: AIProvider
}
function getActiveConfig(): ActiveConfig {
const raw = storage.getJSON<{ provider: AIProvider; modelId: string }>('alpha_settings')
if (raw) {
const provider = raw.provider || 'openrouter'
const model = raw.modelId || 'deepseek/deepseek-chat-v3-0324:free'
const baseUrl = PROVIDER_CONFIG[provider]?.baseUrl || ''
const apiKey = getProviderApiKey(provider)
return { baseUrl, apiKey, model, provider }
}
return {
baseUrl: PROVIDER_CONFIG.openrouter.baseUrl,
apiKey: getProviderApiKey('openrouter'),
model: 'deepseek/deepseek-chat-v3-0324:free',
provider: 'openrouter',
}
}
function buildHeaders(config: ActiveConfig): Record<string, string> {
const h: Record<string, string> = {
'Content-Type': 'application/json',
}
if (config.provider === 'openrouter') {
h['Authorization'] = `Bearer ${config.apiKey}`
h['HTTP-Referer'] = window.location.origin
h['X-Title'] = 'KAPPA Hub Alpha'
} else {
h['Authorization'] = `Bearer ${config.apiKey}`
h['Authorization'] = `Bearer ${config.apiKey}`
}
return h
}
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export async function callAI(
messages: ChatMessage[],
temperature = 0.3,
maxTokens = 4096,
signal?: AbortSignal
): Promise<string> {
const config = getActiveConfig()
if (!config.apiKey) {
const label = PROVIDER_CONFIG[config.provider]?.label || config.provider
throw new Error(`No hay API key configurada para ${label}`)
}
if (!config.baseUrl) {
throw new Error(`El proveedor ${config.provider} no tiene API configurada`)
}
console.log(`[Alpha] AI call — provider: ${config.provider}, model: ${config.model}`)
const body: Record<string, unknown> = {
model: config.model,
messages,
temperature,
max_tokens: maxTokens,
}
const res = await fetch(config.baseUrl, {
method: 'POST',
headers: buildHeaders(config),
body: JSON.stringify(body),
signal,
})
const rawText = await res.text()
if (!res.ok) {
throw new Error(`API error (${config.provider}): ${res.status}${rawText.slice(0, 300)}`)
}
const data = JSON.parse(rawText)
const content = data.choices?.[0]?.message?.content || null
if (!content) throw new Error('Respuesta vacía del proveedor de IA')
return content
}
export async function chatWithAI(
messages: { role: 'user' | 'assistant'; content: string }[],
systemPrompt?: string,
signal?: AbortSignal
): Promise<string> {
const msgs: ChatMessage[] = []
if (systemPrompt) {
msgs.push({ role: 'system', content: systemPrompt })
}
msgs.push(...messages)
return callAI(msgs, 0.7, 2048, signal)
}
const SYSTEM_PROMPT = `Eres un analista funcional experto en metodologías ágiles. Tu tarea es analizar transcripciones de reuniones y extraer Historias de Usuario (HUs) en formato estructurado.
Reglas:
1. Identifica cada requisito, funcionalidad, bug o mejora mencionada en la transcripción
2. Convierte cada uno en una HU con: título claro, descripción detallada, criterios de aceptación
3. Los criterios de aceptación deben ser verificables (condiciones específicas)
4. Usa el formato "Como [rol] quiero [funcionalidad] para [beneficio]" cuando sea posible
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
6. No inventes información que no esté en la transcripción
7. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
Responde SOLO con JSON válido en este formato:
{
"hus": [
{
"title": "Título de la HU",
"description": "Descripción detallada",
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
"priority": "Alta|Media|Baja",
"story_points": 3,
"type": "feature|bug|task|improvement"
}
],
"summary": "Resumen breve del análisis (2-3 líneas)"
}`
export async function analyzeTranscription(
text: string,
projectName?: string,
signal?: AbortSignal
): Promise<AIAnalysisResult> {
const userContent = projectName
? `Proyecto: ${projectName}\n\nTranscripción:\n${text}`
: `Transcripción:\n${text}`
console.log(`[Alpha] AI analyze — text: ${text.length} chars`)
const content = await callAI(
[
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
0.3,
4096,
signal,
)
try {
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
const result: AIAnalysisResult = JSON.parse(jsonStr)
console.log(`[Alpha] AI analysis complete — ${result.hus.length} HUs`)
return result
} catch (e) {
console.error('[Alpha] Failed to parse AI response:', content)
throw new Error('No se pudo parsear la respuesta de la IA')
}
}
+26
View File
@@ -0,0 +1,26 @@
import Dexie from 'dexie'
export interface SettingEntry {
key: string
value: string
}
export interface ProjectDocRecord {
projectId: number
projectName: string
updatedAt: string
sessionCount: number
markdown: string
}
const db = new Dexie('alpha-core') as Dexie & {
settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number>
}
db.version(2).stores({
settings: '&key',
project_docs: '&projectId, projectName, updatedAt',
})
export default db
+4 -3
View File
@@ -1,3 +1,4 @@
import { storage } from '@/services/storage'
import type {
KappaLoginPayload,
KappaLoginResponse,
@@ -23,7 +24,7 @@ class KappaAPI {
private token: string | null = null
constructor() {
this.token = localStorage.getItem('kappa_token')
this.token = storage.get('kappa_token')
}
private get headers(): Record<string, string> {
@@ -66,14 +67,14 @@ class KappaAPI {
)
this.token = data.access || data.token || data.key || null
if (this.token) {
localStorage.setItem('kappa_token', this.token)
storage.set('kappa_token', this.token)
}
return data
}
logout() {
this.token = null
localStorage.removeItem('kappa_token')
storage.remove('kappa_token')
}
get isAuthenticated(): boolean {
+69
View File
@@ -0,0 +1,69 @@
import * as mammoth from 'mammoth'
export type TranscriptionFileType = 'docx' | 'vtt' | 'txt' | 'md'
export interface ParsedTranscription {
fileName: string
fileType: TranscriptionFileType
text: string
size: number
}
const VTT_HEADER_RE = /^WEBVTT\s/mi
const VTT_TIMING_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/m
const VTT_CUE_RE = /\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}/g
function isVTT(text: string): boolean {
return VTT_HEADER_RE.test(text) || VTT_TIMING_RE.test(text)
}
function parseVtt(raw: string): string {
return raw
.replace(VTT_HEADER_RE, '')
.replace(/Kind:.*\n?/gi, '')
.replace(/Language:.*\n?/gi, '')
.split(/\r?\n/)
.filter(line => {
const trimmed = line.trim()
if (!trimmed) return false
if (/^\d+$/.test(trimmed)) return false
if (VTT_TIMING_RE.test(trimmed)) return false
return true
})
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
function detectType(fileName: string): TranscriptionFileType {
const ext = fileName.split('.').pop()?.toLowerCase()
if (ext === 'docx') return 'docx'
if (ext === 'vtt') return 'vtt'
if (ext === 'md') return 'md'
return 'txt'
}
export async function parseFile(file: File): Promise<ParsedTranscription> {
const fileName = file.name
const fileType = detectType(fileName)
const size = file.size
console.log(`[Alpha] Parsing file: ${fileName} (${fileType}, ${size} bytes)`)
let text: string
if (fileType === 'docx') {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.extractRawText({ arrayBuffer })
text = result.value.trim()
} else {
const raw = await file.text()
if (fileType === 'vtt' || isVTT(raw)) {
text = parseVtt(raw)
} else {
text = raw.trim()
}
}
return { fileName, fileType, text, size }
}
+143
View File
@@ -0,0 +1,143 @@
import db from '@/services/db'
import type { SessionExtraction } from '@/services/session-analyzer'
export interface ProjectDoc {
projectId: number
projectName: string
updatedAt: string
sessionCount: number
markdown: string
}
export async function getProjectDoc(projectId: number): Promise<ProjectDoc | null> {
const doc = await db.table('project_docs').get(projectId)
return (doc as ProjectDoc) || null
}
export async function generateProjectDoc(
projectId: number,
projectName: string,
extraction: SessionExtraction,
transcriptionText: string,
fileName: string,
previousDoc?: ProjectDoc | null,
): Promise<ProjectDoc> {
const now = new Date().toISOString().slice(0, 16).replace('T', ' ')
const sessionCount = (previousDoc?.sessionCount || 0) + 1
// ─── Block 1: Summary (replaced) ───────────────────────
const objectivesMd = extraction.objectives.map(o =>
o.isNew ? `- [ ] ${o.text} 🆕` : `- [ ] ${o.text}`
).join('\n')
const tasksMd = extraction.pendingTasks.map((t, i) =>
`| ${i + 1} | [ ] ${t.description} | ${t.origin} | ${now.slice(0, 10)} | ${t.priority} |`
).join('\n')
const commitmentsMd = extraction.commitments.map(c =>
`| ${c.description} | ${c.responsible} | ${c.dueDate} | ${c.status === 'Cumplido' ? '✅' : '⏳'} | — |`
).join('\n')
const completedMd = extraction.completedTasks.map(t => `- [x] ${t}`).join('\n')
const milestonesMd = extraction.commitments
.filter(c => c.dueDate && c.status !== 'Cumplido')
.map(c => `- **${c.dueDate}**: ${c.description}`)
.join('\n')
const block1 = `# 📋 ${projectName} — Resumen Ejecutivo
> ⚠️ Última actualización: ${now}
---
## 🎯 Resumen Ejecutivo
${extraction.summary}
## 🎯 Objetivos
${objectivesMd || '_Sin objetivos registrados_'}
## 📝 Tareas Pendientes
| # | Tarea | Origen | Fecha creación | Prioridad |
|---|-------|--------|----------------|-----------|
${tasksMd || '_Sin tareas pendientes_'}
## ✅ Compromisos
| Compromiso | Responsable | Fecha límite | Estado | Notas |
|------------|-------------|--------------|--------|-------|
${commitmentsMd || '_Sin compromisos_'}
## ✅ Tareas Completadas
${completedMd || '_Sin tareas completadas en esta sesión_'}
## 📅 Próximos Hitos
${milestonesMd || '_Sin hitos próximos_'}
## 📊 Métricas de Seguimiento
- Sesiones registradas: ${sessionCount}
- Tareas pendientes: ${extraction.pendingTasks.length}
- Compromisos cumplidos: ${extraction.commitments.filter(c => c.status === 'Cumplido').length}/${extraction.commitments.length}
- Decisiones tomadas: ${extraction.decisions.length}
---
### Bloque 2: Registro de Sesiones
---
## 📜 Registro Completo de Sesiones
`
// ─── Block 2: Session entry (appended) ─────────────────
const decisionsMd = extraction.decisions.map(d => `- ${d}`).join('\n')
const keyPointsMd = extraction.keyPoints.map(k => `- ${k}`).join('\n')
const sessionEntry = `---
## 📍 Sesión ${sessionCount}: ${extraction.sessionTitle}
**Archivo fuente:** \`${fileName}\`
**Fecha:** ${now}
### Resumen de la sesión
${extraction.summary}
### Tareas identificadas en esta sesión
${extraction.pendingTasks.map(t => `- [ ] ${t.description} (_${t.priority}_)`).join('\n') || '_Ninguna_'}
### Decisiones tomadas
${decisionsMd || '_Ninguna_'}
### Puntos clave
${keyPointsMd || '_Ninguno_'}
### Transcripción completa
\`\`\`
${transcriptionText}
\`\`\`
`
// ─── Assemble document ─────────────────────────────────
const previousSessions = previousDoc
? previousDoc.markdown.split('---\n\n## 📜 Registro Completo de Sesiones')[1] || ''
: ''
const markdown = `${block1}${previousSessions}\n${sessionEntry}\n`
const doc: ProjectDoc = {
projectId,
projectName,
updatedAt: now,
sessionCount,
markdown,
}
// Save to Dexie
await db.table('project_docs').put(doc)
return doc
}
+74
View File
@@ -0,0 +1,74 @@
import { callAI } from '@/services/ai'
export interface SessionExtraction {
sessionTitle: string
summary: string
objectives: { text: string; isNew: boolean }[]
pendingTasks: { description: string; origin: string; priority: string }[]
commitments: { description: string; responsible: string; dueDate: string; status: string }[]
decisions: string[]
completedTasks: string[]
keyPoints: string[]
}
const SESSION_SYSTEM_PROMPT = `Eres un asistente de gestión de proyectos. Analizás transcripciones de reuniones y extraés información estructurada.
Reglas:
1. Identificá el título de la sesión basado en el contenido y fecha
2. Extraé un resumen ejecutivo de 2-3 oraciones
3. Listá objetivos mencionados, marcando cuáles son NUEVOS vs existentes
4. Extraé tareas pendientes con su origen y prioridad (Alta/Media/Baja)
5. Identificá compromisos con responsable, fecha límite y estado
6. Listá decisiones tomadas durante la sesión
7. Detectá tareas completadas (si hay evidencia)
8. Incluí puntos clave, bloqueos o descubrimientos
9. No inventes información que no esté en la transcripción
10. Respondé SOLO con JSON válido
Formato de respuesta JSON:
{
"sessionTitle": "Título descriptivo de la sesión",
"summary": "Resumen ejecutivo de 2-3 oraciones",
"objectives": [
{ "text": "Descripción del objetivo", "isNew": true }
],
"pendingTasks": [
{ "description": "Descripción de la tarea", "origin": "Sesión o contexto", "priority": "Alta|Media|Baja" }
],
"commitments": [
{ "description": "Compromiso", "responsible": "Nombre", "dueDate": "YYYY-MM-DD", "status": "Pendiente|Cumplido|Vencido" }
],
"decisions": ["Decisión 1", "Decisión 2"],
"completedTasks": ["Tarea completada 1"],
"keyPoints": ["Punto clave 1"]
}`
export async function analyzeSession(
transcription: string,
projectName: string,
signal?: AbortSignal
): Promise<SessionExtraction> {
const userContent = `Proyecto: ${projectName}\n\nTranscripción:\n${transcription}`
console.log(`[Alpha] Session analyze — project: ${projectName}, text: ${transcription.length} chars`)
const content = await callAI(
[
{ role: 'system', content: SESSION_SYSTEM_PROMPT },
{ role: 'user', content: userContent },
],
0.3,
4096,
signal,
)
try {
const jsonStr = content.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim()
const result: SessionExtraction = JSON.parse(jsonStr)
console.log(`[Alpha] Session analysis complete — ${result.pendingTasks.length} tasks, ${result.decisions.length} decisions`)
return result
} catch (e) {
console.error('[Alpha] Failed to parse session analysis:', content)
throw new Error('No se pudo parsear el análisis de la sesión')
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Cache-Aside + Write-Through storage para Alpha.
*
* ─── Patrón ─────────────────────────────────────────────────────
*
* L1 (rápido) → Map en memoria ~0ms
* L2 (persistente) → Dexie IndexedDB ~5-50ms
* L3 (fallback) → localStorage ~1ms
*
* ─── Lectura (Cache-Aside) ─────────────────────────────────────
*
* get(key):
* 1. L1 hit → return (instantáneo)
* 2. L2 hit → poblar L1 → return
* 3. L3 hit → poblar L2 + L1 → return
* 4. Miss → return null
*
* ─── Escritura (Write-Through) ─────────────────────────────────
*
* set(key, value):
* 1. Escribir L1 (instantáneo)
* 2. Escribir L2 (Dexie, async await)
* 3. Escribir L3 (localStorage, sync)
* Si L2 falla → el dato sigue en L1 + L3 (consistencia eventual)
*
* ─── ¿Por qué este patrón? ─────────────────────────────────────
*
* - Cache-Aside: control explícito de qué se cachea y cuándo
* - Write-Through: el cache siempre refleja la fuente de verdad
* - L3 (localStorage) actúa como quorum: si IndexedDB falla,
* los datos no se pierden
* - Para RUMBO: mismo patrón, cambia L2 de Dexie a Turso/libSQL
* y L3 pasa a ser un archivo JSON en ~/.rumbo/cache.json
*
* ─── Uso ───────────────────────────────────────────────────────
*
* import { storage } from '@/services/storage'
* await storage.init()
* storage.get('key') → string | null (sync)
* await storage.set('k', v) → void (async)
* await storage.remove('k') → void (async)
* storage.getJSON<T>('k') → T | null (sync)
* await storage.setJSON() → void (async)
*/
import db from '@/services/db'
class AppStorage {
/** L1: cache en memoria */
private cache = new Map<string, { value: string; ttl: number | null }>()
private loaded = false
private initPromise: Promise<void> | null = null
// ─── Init ──────────────────────────────────────────────────
async init() {
if (this.initPromise) return this.initPromise
this.initPromise = this._load()
return this.initPromise
}
private async _load() {
const all = await db.settings.toArray()
for (const entry of all) {
this.cache.set(entry.key, { value: entry.value, ttl: null })
}
this.loaded = true
console.log(`[Storage] Init OK — ${all.length} entries en L1`)
}
isReady() { return this.loaded }
// ─── Lectura: Cache-Aside (L1 → L2 → L3) ────────────────
get(key: string): string | null {
// 1. L1 hit
const entry = this.cache.get(key)
if (entry) {
if (entry.ttl !== null && entry.ttl < Date.now()) {
this.cache.delete(key)
} else {
return entry.value
}
}
// 2. L2 (Dexie)
// Nota: Dexie.get() es async. Para mantener get() sync,
// delegamos la carga asíncrona a init(). Si no está en L1
// después de init, cae a L3.
// En la práctica, init() carga TODO al arranque, así que
// L1 siempre está poblado para keys existentes.
// 3. L3 (localStorage) — fallback + migración
const fallback = localStorage.getItem(key)
if (fallback !== null) {
this.cache.set(key, { value: fallback, ttl: null })
// Write-Through hacia L2 (fire-and-forget)
db.settings.put({ key, value: fallback }).catch(() => {})
return fallback
}
return null
}
getJSON<T>(key: string): T | null {
const raw = this.get(key)
if (!raw) return null
try { return JSON.parse(raw) as T } catch { return null }
}
// ─── Escritura: Write-Through (L1 + L2 + L3) ─────────────
async set(key: string, value: string) {
// 1. L1 (instantáneo)
this.cache.set(key, { value, ttl: null })
// 2. L2 + L3 en paralelo
await Promise.all([
db.settings.put({ key, value }).catch(e => {
console.error(`[Storage] L2 error writing "${key}":`, e)
}),
Promise.resolve().then(() => {
localStorage.setItem(key, value)
}),
])
}
async setJSON(key: string, value: unknown) {
await this.set(key, JSON.stringify(value))
}
async remove(key: string) {
this.cache.delete(key)
await Promise.all([
db.settings.delete(key).catch(e => {
console.error(`[Storage] L2 error deleting "${key}":`, e)
}),
Promise.resolve().then(() => {
localStorage.removeItem(key)
}),
])
}
// ─── Utilidades ───────────────────────────────────────────
/** Expone el tamaño del cache L1 (debug) */
get cacheSize() { return this.cache.size }
}
export const storage = new AppStorage()
+2 -1
View File
@@ -1,11 +1,12 @@
import * as XLSX from 'xlsx'
import { kappa } from '@/services/kappa-api'
import { storage } from '@/services/storage'
import type { HuDraftRecord } from '@/services/tauri-db'
const BASE = '/api'
async function uploadExcel(initiativeId: number, file: Blob): Promise<Response> {
const token = localStorage.getItem('kappa_token')
const token = storage.get('kappa_token')
const formData = new FormData()
formData.append('file', file, 'HistoriasUsuario.xlsx')
return fetch(`${BASE}/userstorys/upload-excel/${initiativeId}/`, {