From 8c1bd6a3dc4b9e4575e1b24321126f78e8f81582 Mon Sep 17 00:00:00 2001 From: Eric Lott Date: Tue, 24 Mar 2026 16:31:17 -0500 Subject: [PATCH 1/2] Updated workflow for user-to-aghent interactions in platform UI --- backend/models/index.js | 15 ++- backend/routes/projectRoutes.js | 34 +++++- backend/server.js | 2 +- backend/services/projectService.js | 96 ++++++++++----- .../tests/integration/api.integration.test.js | 18 +++ .../integration/task-035.integration.test.js | 12 +- frontend/src/App.jsx | 8 +- frontend/src/components/SettingsModal.jsx | 34 +++--- frontend/src/hooks/useAppLogic.js | 47 +++++-- frontend/src/hooks/useChatLogic.js | 115 +++++++++++++++++- frontend/src/hooks/useProjectManagement.js | 1 + frontend/src/services/agentService.js | 1 + frontend/src/services/agentValidator.js | 8 ++ frontend/src/services/apiService.js | 18 +++ frontend/src/services/prompts.js | 23 +++- frontend/src/test/agentService.test.js | 12 ++ 16 files changed, 376 insertions(+), 68 deletions(-) diff --git a/backend/models/index.js b/backend/models/index.js index 1d3a64b..a1d4b6b 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -29,6 +29,12 @@ const Decision = sequelize.define('Decision', { clusterY: { type: DataTypes.FLOAT, allowNull: true }, clusterLabel: { type: DataTypes.STRING, allowNull: true }, icon: { type: DataTypes.STRING, allowNull: true }, + acceptanceCriteria: { type: DataTypes.JSON, defaultValue: [] }, + technicalContext: { type: DataTypes.TEXT, allowNull: true }, + dependencies: { type: DataTypes.JSON, defaultValue: [] }, + priority: { type: DataTypes.STRING, allowNull: true }, + options: { type: DataTypes.JSON, allowNull: true }, + rawData: { type: DataTypes.JSON, defaultValue: {} }, PillarId: { type: DataTypes.INTEGER } }); @@ -46,6 +52,12 @@ const AuditLog = sequelize.define('AuditLog', { isAgent: { type: DataTypes.BOOLEAN, defaultValue: false } }); +const AppSettings = sequelize.define('AppSettings', { + singletonKey: { type: DataTypes.STRING, unique: true, allowNull: false, defaultValue: 'global' }, + provider: { type: DataTypes.STRING, allowNull: false, defaultValue: 'mock' }, + keys: { type: DataTypes.JSON, allowNull: false, defaultValue: {} } +}); + // Associations Project.hasMany(Pillar, { onDelete: 'CASCADE' }); Pillar.belongsTo(Project); @@ -76,5 +88,6 @@ module.exports = { Pillar, Decision, DecisionRelationship, - AuditLog + AuditLog, + AppSettings }; diff --git a/backend/routes/projectRoutes.js b/backend/routes/projectRoutes.js index c129d56..449c83e 100644 --- a/backend/routes/projectRoutes.js +++ b/backend/routes/projectRoutes.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { Project } = require('../models'); +const { Project, AppSettings } = require('../models'); const { getProjectTree, saveProjectState, @@ -90,6 +90,38 @@ router.post('/save-state', async (req, res) => { } }); +// App settings (persisted server-side, no local-only storage) +router.get('/settings', async (req, res) => { + try { + const [settings] = await AppSettings.findOrCreate({ + where: { singletonKey: 'global' }, + defaults: { + singletonKey: 'global', + provider: 'mock', + keys: { openai: '', anthropic: '', gemini: '' } + } + }); + res.json({ provider: settings.provider, keys: settings.keys || {} }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.put('/settings', async (req, res) => { + try { + const provider = typeof req.body.provider === 'string' ? req.body.provider : 'mock'; + const keys = req.body.keys && typeof req.body.keys === 'object' ? req.body.keys : {}; + const [settings] = await AppSettings.findOrCreate({ + where: { singletonKey: 'global' }, + defaults: { singletonKey: 'global', provider, keys } + }); + await settings.update({ provider, keys }); + res.json({ success: true, provider: settings.provider, keys: settings.keys || {} }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // Link decisions router.post('/decisions/:id/link', async (req, res) => { try { diff --git a/backend/server.js b/backend/server.js index f176622..e9edb11 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,7 @@ const { sequelize } = db; const projectRoutes = require('./routes/projectRoutes'); const agentRoutes = require('./routes/agentRoutes'); -const models = { Project: db.Project, Pillar: db.Pillar, Decision: db.Decision, AuditLog: db.AuditLog }; +const models = { Project: db.Project, Pillar: db.Pillar, Decision: db.Decision, AuditLog: db.AuditLog, AppSettings: db.AppSettings }; const app = express(); app.use(cors()); diff --git a/backend/services/projectService.js b/backend/services/projectService.js index b6efe2d..f427936 100644 --- a/backend/services/projectService.js +++ b/backend/services/projectService.js @@ -1,5 +1,65 @@ const { Project, Pillar, Decision, DecisionRelationship, AuditLog, sequelize } = require('../models'); +const normalizeArray = (value) => { + if (Array.isArray(value)) return value; + if (value === null || value === undefined) return []; + return [value]; +}; + +const buildDecisionPersistenceShape = (decisionInput = {}) => { + const acceptanceCriteria = decisionInput.acceptance_criteria ?? decisionInput.acceptanceCriteria; + const technicalContext = decisionInput.technical_context ?? decisionInput.technicalContext; + + return { + question: decisionInput.question, + icon: decisionInput.icon, + context: decisionInput.context, + answer: decisionInput.answer, + conflict: decisionInput.conflict, + rationale: decisionInput.rationale, + constraints: decisionInput.constraints, + tags: normalizeArray(decisionInput.tags), + acceptanceCriteria: normalizeArray(acceptanceCriteria), + technicalContext: technicalContext ?? null, + dependencies: normalizeArray(decisionInput.dependencies), + priority: decisionInput.priority ?? null, + options: Array.isArray(decisionInput.options) ? decisionInput.options : null, + rawData: decisionInput + }; +}; + +const buildDecisionResponseShape = (decisionRecord) => { + const raw = decisionRecord.rawData && typeof decisionRecord.rawData === 'object' ? decisionRecord.rawData : {}; + const linked = decisionRecord.linkedTo ? decisionRecord.linkedTo.map(lt => ({ + id: lt.decisionId, + type: lt.DecisionRelationship.type, + strength: lt.DecisionRelationship.strength + })) : []; + + return { + ...raw, + id: decisionRecord.decisionId, + question: decisionRecord.question, + icon: decisionRecord.icon, + context: decisionRecord.context, + answer: decisionRecord.answer, + conflict: decisionRecord.conflict, + rationale: decisionRecord.rationale, + constraints: decisionRecord.constraints, + tags: Array.isArray(decisionRecord.tags) ? decisionRecord.tags : [], + acceptance_criteria: Array.isArray(decisionRecord.acceptanceCriteria) + ? decisionRecord.acceptanceCriteria + : normalizeArray(raw.acceptance_criteria ?? raw.acceptanceCriteria), + technical_context: decisionRecord.technicalContext ?? raw.technical_context ?? raw.technicalContext ?? null, + dependencies: Array.isArray(decisionRecord.dependencies) + ? decisionRecord.dependencies + : normalizeArray(raw.dependencies), + priority: decisionRecord.priority ?? raw.priority ?? null, + options: Array.isArray(decisionRecord.options) ? decisionRecord.options : (raw.options ?? null), + links: linked + }; +}; + const getProjectTree = async (projectId) => { const project = await Project.findByPk(projectId); if (!project) return null; @@ -26,22 +86,7 @@ const getProjectTree = async (projectId) => { title: p.title, description: p.description, icon: p.icon, - decisions: (p.Decisions || []).map(d => ({ - id: d.decisionId, - question: d.question, - icon: d.icon, - context: d.context, - answer: d.answer, - conflict: d.conflict, - rationale: d.rationale, - constraints: d.constraints, - tags: d.tags, - links: d.linkedTo ? d.linkedTo.map(lt => ({ - id: lt.decisionId, - type: lt.DecisionRelationship.type, - strength: lt.DecisionRelationship.strength - })) : [] - })), + decisions: (p.Decisions || []).map(buildDecisionResponseShape), subcategories: buildPillarTree(p.id) })); }; @@ -110,6 +155,7 @@ const saveProjectState = async (idea, pillars, projectId, isAgent = false) => { if (p.decisions) { for (const d of p.decisions) { + const persistedShape = buildDecisionPersistenceShape(d); let decision = await Decision.findOne({ where: { decisionId: d.id, PillarId: pillar.id }, transaction: t @@ -117,26 +163,12 @@ const saveProjectState = async (idea, pillars, projectId, isAgent = false) => { if (decision) { await decision.update({ - question: d.question, - icon: d.icon, - context: d.context, - answer: d.answer, - conflict: d.conflict, - rationale: d.rationale, - constraints: d.constraints, - tags: d.tags + ...persistedShape }, { transaction: t }); } else { decision = await Decision.create({ decisionId: d.id, - question: d.question, - icon: d.icon, - context: d.context, - answer: d.answer, - conflict: d.conflict, - rationale: d.rationale, - constraints: d.constraints, - tags: d.tags, + ...persistedShape, PillarId: pillar.id }, { transaction: t }); } diff --git a/backend/tests/integration/api.integration.test.js b/backend/tests/integration/api.integration.test.js index 442e8bf..bbff0da 100644 --- a/backend/tests/integration/api.integration.test.js +++ b/backend/tests/integration/api.integration.test.js @@ -200,4 +200,22 @@ describe('Backend integration: persistence, rollback, and health', () => { expect(response.body.database).toBe('CONNECTED'); expect(typeof response.body.timestamp).toBe('string'); }); + + test('persists app settings in backend database', async () => { + const saveSettings = await request(app) + .put('/api/settings') + .send({ + provider: 'openai', + keys: { openai: 'sk-test', anthropic: '', gemini: '' } + }); + + expect(saveSettings.status).toBe(200); + expect(saveSettings.body.success).toBe(true); + expect(saveSettings.body.provider).toBe('openai'); + + const fetchSettings = await request(app).get('/api/settings'); + expect(fetchSettings.status).toBe(200); + expect(fetchSettings.body.provider).toBe('openai'); + expect(fetchSettings.body.keys.openai).toBe('sk-test'); + }); }); diff --git a/backend/tests/integration/task-035.integration.test.js b/backend/tests/integration/task-035.integration.test.js index 1556036..d4a4392 100644 --- a/backend/tests/integration/task-035.integration.test.js +++ b/backend/tests/integration/task-035.integration.test.js @@ -28,7 +28,12 @@ describe('Task-035: Decision Relationships Integration', () => { answer: 'Answer 1', rationale: 'Because we need it.', constraints: 'Must be fast.', - tags: ['critical', 'backend'] + tags: ['critical', 'backend'], + acceptance_criteria: ['Criterion A', 'Criterion B'], + technical_context: 'Use provider SDK X.', + dependencies: ['feat_auth'], + priority: 'P0', + options: [{ id: 'opt-a', label: 'Option A', icon: null }] }] }] }; @@ -45,6 +50,11 @@ describe('Task-035: Decision Relationships Integration', () => { expect(decision.rationale).toBe('Because we need it.'); expect(decision.constraints).toBe('Must be fast.'); expect(decision.tags).toContain('critical'); + expect(decision.acceptance_criteria).toEqual(['Criterion A', 'Criterion B']); + expect(decision.technical_context).toBe('Use provider SDK X.'); + expect(decision.dependencies).toEqual(['feat_auth']); + expect(decision.priority).toBe('P0'); + expect(decision.options).toEqual([{ id: 'opt-a', label: 'Option A', icon: null }]); }); test('should link two decisions and retrieve their relationship', async () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cfd7dd2..28267c8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,7 +31,7 @@ function App() { setIsSettingsOpen, isNotificationsOpen, setIsNotificationsOpen, - setLlmConfig, + llmConfig, handleNewProject, handleSelectProject, handleSendMessage, @@ -39,7 +39,8 @@ function App() { handleAddFeature, handleDeleteFeature, handleEditFeature, - handleExport + handleExport, + handleSaveLlmConfig } = useAppLogic(); return ( @@ -54,8 +55,9 @@ function App() { {isSettingsOpen && ( setIsSettingsOpen(false)} - onSave={config => setLlmConfig(config)} + onSave={handleSaveLlmConfig} /> )} diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index 762d8b1..f26c236 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -1,27 +1,25 @@ import React, { useState } from 'react'; -export default function SettingsModal({ onClose, onSave }) { - const [keys, setKeys] = useState(() => { - const saved = localStorage.getItem('cartograph_keys'); - return saved ? JSON.parse(saved) : { openai: '', anthropic: '', gemini: '' }; - }); - const [provider, setProvider] = useState(() => { - const savedProvider = localStorage.getItem('cartograph_provider'); - return savedProvider || 'mock'; - }); +export default function SettingsModal({ onClose, onSave, currentConfig }) { + const [keys, setKeys] = useState(currentConfig?.keys || { openai: '', anthropic: '', gemini: '' }); + const [provider, setProvider] = useState(currentConfig?.provider || 'mock'); + const [isSaving, setIsSaving] = useState(false); - const handleSave = () => { - localStorage.setItem('cartograph_keys', JSON.stringify(keys)); - localStorage.setItem('cartograph_provider', provider); - onSave({ keys, provider }); - onClose(); + const handleSave = async () => { + setIsSaving(true); + try { + await onSave({ keys, provider }); + onClose(); + } finally { + setIsSaving(false); + } }; return (

LLM Settings (BYOK)

-

Provide your APIs keys to empower Cartograph with real AI. Keys are stored locally in your browser.

+

Provide your APIs keys to empower Cartograph with real AI. Settings are persisted in the backend database.

@@ -46,8 +44,10 @@ export default function SettingsModal({ onClose, onSave }) { )}
- - + +
diff --git a/frontend/src/hooks/useAppLogic.js b/frontend/src/hooks/useAppLogic.js index c3c5732..a6eab00 100644 --- a/frontend/src/hooks/useAppLogic.js +++ b/frontend/src/hooks/useAppLogic.js @@ -1,10 +1,11 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { validateBlueprint } from '../services/validationService'; import { useChatLogic } from './useChatLogic'; import { usePillarLogic } from './usePillarLogic'; import { useProjectManagement } from './useProjectManagement'; import { generateBlueprintZip } from '../services/exportService'; import { findNodeById } from '../utils/treeUtils'; +import { fetchAppSettings, saveAppSettings } from '../services/apiService'; export function useAppLogic() { // 1. Core State @@ -21,13 +22,9 @@ export function useAppLogic() { const [viewMode, setViewMode] = useState('pillar'); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); - const [llmConfig, setLlmConfig] = useState(() => { - const savedKeys = localStorage.getItem('cartograph_keys'); - const savedProvider = localStorage.getItem('cartograph_provider'); - return { - keys: savedKeys ? JSON.parse(savedKeys) : { openai: '', anthropic: '', gemini: '' }, - provider: savedProvider || 'mock' - }; + const [llmConfig, setLlmConfig] = useState({ + keys: { openai: '', anthropic: '', gemini: '' }, + provider: 'mock' }); // 2. Logic Containers @@ -47,6 +44,24 @@ export function useAppLogic() { const { handleSendMessage } = useChatLogic(state, setters); const { handleUpdateDecision, handleAddFeature, handleDeleteFeature, handleEditFeature } = usePillarLogic(state, setters); + useEffect(() => { + let isMounted = true; + (async () => { + try { + const settings = await fetchAppSettings(); + if (isMounted) { + setLlmConfig({ + provider: settings.provider || 'mock', + keys: settings.keys || { openai: '', anthropic: '', gemini: '' } + }); + } + } catch (err) { + console.error('Failed to load backend app settings:', err); + } + })(); + return () => { isMounted = false; }; + }, []); + // 3. Proactive Validation (Derived observations) const agentFeedback = useMemo(() => { if (pillars.length === 0) return { isValid: true, errors: [], warnings: [], metadataReport: [] }; @@ -74,6 +89,20 @@ export function useAppLogic() { } }; + const handleSaveLlmConfig = async (config) => { + setErrorMessage(null); + try { + const saved = await saveAppSettings(config); + setLlmConfig({ + provider: saved.provider || config.provider || 'mock', + keys: saved.keys || config.keys || { openai: '', anthropic: '', gemini: '' } + }); + } catch (err) { + setErrorMessage(err.message || 'Failed to save settings.'); + throw err; + } + }; + const activePillar = activePillarId ? findNodeById(pillars, activePillarId) : null; return { @@ -83,6 +112,6 @@ export function useAppLogic() { setViewMode, setIsSettingsOpen, setIsNotificationsOpen, setLlmConfig, handleNewProject, handleSelectProject, handleSendMessage, handleUpdateDecision, handleAddFeature, handleDeleteFeature, handleEditFeature, handleExport, - activePillar + activePillar, handleSaveLlmConfig }; } diff --git a/frontend/src/hooks/useChatLogic.js b/frontend/src/hooks/useChatLogic.js index 7acf900..78603b6 100644 --- a/frontend/src/hooks/useChatLogic.js +++ b/frontend/src/hooks/useChatLogic.js @@ -1,6 +1,106 @@ import { generatePillarsFromIdea, processChatTurn, generateCategoriesForPillar } from '../services/agentService'; import { saveStateToBackend } from '../services/apiService'; -import { updateNodeDecisions } from '../utils/treeUtils'; +import { addDecisionToPillar, findNodeById, updateNodeDecisions } from '../utils/treeUtils'; + +const normalizeText = (value = '') => value.toLowerCase(); + +const findFirstNode = (nodes, predicate) => { + for (const node of nodes) { + if (predicate(node)) return node; + if (node.subcategories?.length) { + const found = findFirstNode(node.subcategories, predicate); + if (found) return found; + } + } + return null; +}; + +const decisionMatches = (decision, matcher) => { + if (!decision) return false; + if (matcher.id && decision.id === matcher.id) return true; + if (!matcher.pattern) return false; + const haystack = `${decision.question || ''} ${decision.context || ''}`.toLowerCase(); + return matcher.pattern.test(haystack); +}; + +const upsertDecisionOnNode = (pillars, targetId, decision, matcher = {}) => { + const target = findNodeById(pillars, targetId); + if (!target) return pillars; + + const existing = (target.decisions || []).find((d) => decisionMatches(d, matcher) || d.id === decision.id); + if (existing) { + return updateNodeDecisions(pillars, existing.id, (d) => ({ ...d, ...decision, id: d.id })); + } + + return addDecisionToPillar(pillars, targetId, decision); +}; + +const applyRealtimeStripeFallback = (pillars, message) => { + const normalized = normalizeText(message); + if (!/\bstripe\b/.test(normalized)) return pillars; + + const mentionsSubscriptions = /\bsubscription|subscriptions|billing|recurring\b/.test(normalized); + const mentionsWebhooks = /\bwebhook|webhooks|event endpoint|event endpoints\b/.test(normalized); + + let next = pillars; + + const featuresNode = findFirstNode(next, (node) => normalizeText(node.title).includes('feature')); + if (featuresNode && (mentionsSubscriptions || mentionsWebhooks)) { + next = upsertDecisionOnNode( + next, + featuresNode.id, + { + id: 'feat_stripe_subscriptions', + question: 'Stripe subscription payments', + context: 'Support recurring subscriptions via Stripe Checkout/Billing and synchronize status to user accounts.', + answer: 'Included' + }, + { pattern: /stripe.*subscription|subscription.*stripe|stripe.*billing/ } + ); + } + + const apiNode = findFirstNode( + next, + (node) => normalizeText(node.title).includes('api') || normalizeText(node.id).includes('api') + ); + if (apiNode && (mentionsWebhooks || mentionsSubscriptions)) { + next = upsertDecisionOnNode( + next, + apiNode.id, + { + id: 'api_stripe_webhooks', + question: 'Stripe webhook endpoint suite', + context: 'Implement webhook endpoints for subscription lifecycle events (checkout/session completion, invoice, and subscription status changes).', + answer: 'Required' + }, + { pattern: /stripe.*webhook|webhook.*stripe|subscription.*event/ } + ); + } + + return next; +}; + +const sanitizeAgentReply = (reply, { updatedDecisionsCount = 0 } = {}) => { + if (typeof reply !== 'string' || !reply.trim()) return 'Captured. Proceeding to the next architectural step.'; + let nextReply = reply.trim(); + + // If the user already made a clear choice, avoid confirmation loops. + if (updatedDecisionsCount > 0) { + nextReply = nextReply + .replace(/\bdo you confirm\b/gi, 'noted') + .replace(/\blet'?s confirm\b/gi, 'captured') + .replace(/\bplease confirm\b/gi, 'captured'); + } + + // Keep the conversation focused: at most one clarifying question in a turn. + const questionMarkCount = (nextReply.match(/\?/g) || []).length; + if (questionMarkCount > 1) { + const firstQuestionIdx = nextReply.indexOf('?'); + nextReply = nextReply.slice(0, firstQuestionIdx + 1).trim(); + } + + return nextReply; +}; export function useChatLogic(state, setters) { const { messages, pillars, projectId, llmConfig } = state; @@ -60,6 +160,12 @@ export function useChatLogic(state, setters) { const result = await processChatTurn(newMessages, pillars, llmConfig); let nextPillars = [...pillars]; if (result.newCategories?.length > 0) nextPillars = [...nextPillars, ...result.newCategories]; + if (result.newDecisions?.length > 0) { + result.newDecisions.forEach((insertion) => { + if (!insertion?.targetId || !insertion?.decision) return; + nextPillars = upsertDecisionOnNode(nextPillars, insertion.targetId, insertion.decision); + }); + } if (result.updatedDecisions?.length > 0) { nextPillars = updateNodeDecisions(nextPillars, result.updatedDecisions, (d, update) => ({ ...d, answer: update.answer })); } @@ -68,8 +174,13 @@ export function useChatLogic(state, setters) { nextPillars = updateNodeDecisions(nextPillars, conflict.decisionIds, (d) => ({ ...d, conflict: conflict.description })); }); } + + const latestUserMessage = newMessages[newMessages.length - 1]?.content || ''; + nextPillars = applyRealtimeStripeFallback(nextPillars, latestUserMessage); + setPillars(nextPillars); - setMessages([...newMessages, { role: 'agent', content: result.reply }]); + const reply = sanitizeAgentReply(result.reply, { updatedDecisionsCount: result.updatedDecisions?.length || 0 }); + setMessages([...newMessages, { role: 'agent', content: reply }]); const ideaMsg = newMessages.find(m => m.role === 'user'); if (ideaMsg) { diff --git a/frontend/src/hooks/useProjectManagement.js b/frontend/src/hooks/useProjectManagement.js index 5e0b389..53f7d4c 100644 --- a/frontend/src/hooks/useProjectManagement.js +++ b/frontend/src/hooks/useProjectManagement.js @@ -31,6 +31,7 @@ export function useProjectManagement(state, setters) { setProjectId(null); setPillars([]); setActivePillarId(null); + setIsProjectsOpen(false); setMessages([ { role: 'agent', content: "New session started! Describe the application you want to build." } ]); diff --git a/frontend/src/services/agentService.js b/frontend/src/services/agentService.js index e03ca8d..1a65884 100644 --- a/frontend/src/services/agentService.js +++ b/frontend/src/services/agentService.js @@ -181,5 +181,6 @@ const mockChatTurn = async () => ({ reply: "Got it, evaluating your decision...", updatedDecisions: [], newCategories: [], + newDecisions: [], conflicts: [] }); diff --git a/frontend/src/services/agentValidator.js b/frontend/src/services/agentValidator.js index 587d30e..6c79def 100644 --- a/frontend/src/services/agentValidator.js +++ b/frontend/src/services/agentValidator.js @@ -141,6 +141,14 @@ export const validateChatTurnOutput = (output, contextLabel) => { validateCategoryNode(category, `root.newCategories[${index}]`, contextLabel) ); + output.newDecisions = Array.isArray(output.newDecisions) ? output.newDecisions : []; + output.newDecisions.forEach((insertion, index) => { + const path = `root.newDecisions[${index}]`; + assertPlainObject(insertion, path, contextLabel); + assertNonEmptyString(insertion.targetId, `${path}.targetId`, contextLabel); + validateDecisionNode(insertion.decision, `${path}.decision`, contextLabel); + }); + assertArray(output.conflicts, 'root.conflicts', contextLabel); output.conflicts.forEach((conflict, index) => { const path = `root.conflicts[${index}]`; diff --git a/frontend/src/services/apiService.js b/frontend/src/services/apiService.js index df9a0cb..2199c1c 100644 --- a/frontend/src/services/apiService.js +++ b/frontend/src/services/apiService.js @@ -49,3 +49,21 @@ export const archiveProject = async (id) => { if (!response.ok) throw new Error(`Failed to archive project ${id} (Status ${response.status})`); return await response.json(); }; + +export const fetchAppSettings = async () => { + const apiUrl = import.meta.env.VITE_API_URL || ''; + const response = await fetch(`${apiUrl}/api/settings`); + if (!response.ok) throw new Error(`Failed to fetch app settings (Status ${response.status})`); + return await response.json(); +}; + +export const saveAppSettings = async (settings) => { + const apiUrl = import.meta.env.VITE_API_URL || ''; + const response = await fetch(`${apiUrl}/api/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + if (!response.ok) throw new Error(`Failed to save app settings (Status ${response.status})`); + return await response.json(); +}; diff --git a/frontend/src/services/prompts.js b/frontend/src/services/prompts.js index 4ca9413..26e4eb8 100644 --- a/frontend/src/services/prompts.js +++ b/frontend/src/services/prompts.js @@ -86,10 +86,20 @@ You MUST be proactive. You will receive the current state of Pillars/Decisions a Your job: 1. Scan the Current Architecture State. Find decisions where "answer" is null. -2. In your "reply", proactively guide the user sequentially. Ask them about these pending architectural decisions one at a time or in logical groups. Do NOT passively wait for them. Drive the conversation carefully. +2. In your "reply", proactively guide the user sequentially. Do NOT passively wait for them. Drive the conversation carefully. 3. If they answer your questions, extract the decisions into "updatedDecisions". 4. Identify logical contradictions and output them in "conflicts". 5. If a new domain is introduced, define new categories in "newCategories". +6. If the user introduces a new requirement that belongs inside an EXISTING pillar/category, append it in "newDecisions" with a valid "targetId" that already exists in Current Architecture State. +7. For payments/subscriptions/webhooks, ALWAYS add: + - a feature decision under "pillar-features" + - an API integration decision under the most relevant API/Backend category. +8. DECISION VELOCITY POLICY: + - If user intent is clear, DO NOT ask for confirmation. Record the decision directly in "updatedDecisions". + - Ask at most ONE clarifying question per turn, and only when ambiguity would materially change implementation. + - Prefer strong defaults and recommendations over broad multi-question checklists. + - Never "speedrun" by asking many unrelated decisions at once. + - If you set a reasonable default due to partial ambiguity, state the default briefly and keep moving. You MUST respond with ONLY a valid JSON object matching this schema exactly! NO markdown wrappers: { @@ -100,6 +110,17 @@ You MUST respond with ONLY a valid JSON object matching this schema exactly! NO "newCategories": [ // Array of completely new Pillar/Category objects (recursively matching the Pillar schema) if applicable. ], + "newDecisions": [ + { + "targetId": "existing_pillar_or_category_id", + "decision": { + "id": "decision_id_string", + "question": "The architectural question?", + "context": "Contextual advice.", + "answer": "Included or resolved answer" + } + } + ], "conflicts": [ { "description": "E.g. They chose CosmosDB but also MySQL for the same dataset.", "decisionIds": ["id1", "id2"] } ] diff --git a/frontend/src/test/agentService.test.js b/frontend/src/test/agentService.test.js index ae77cef..491f662 100644 --- a/frontend/src/test/agentService.test.js +++ b/frontend/src/test/agentService.test.js @@ -60,6 +60,17 @@ describe('agentService', () => { reply: 'Hello', updatedDecisions: [], newCategories: [], + newDecisions: [ + { + targetId: 'pillar-features', + decision: { + id: 'feat_stripe_subscriptions', + question: 'Stripe subscription payments', + context: 'Support recurring billing', + answer: 'Included' + } + } + ], conflicts: [] }); const mockResponse = { @@ -75,6 +86,7 @@ describe('agentService', () => { ); expect(result.reply).toBe('Hello'); + expect(result.newDecisions).toHaveLength(1); expect(fetch).toHaveBeenCalledWith('/api/agent/complete', expect.any(Object)); }); }); From 0e8724284051a392b70da3b8afe546ce2e0ed3cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:31:57 +0000 Subject: [PATCH 2/2] chore: move task to pull_requested on PR submission --- .../task-041-add-semantic-cluster-view-to-frontend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename agent-pack/04-task-system/tasks/{in_progress => pull_requested}/task-041-add-semantic-cluster-view-to-frontend.md (98%) diff --git a/agent-pack/04-task-system/tasks/in_progress/task-041-add-semantic-cluster-view-to-frontend.md b/agent-pack/04-task-system/tasks/pull_requested/task-041-add-semantic-cluster-view-to-frontend.md similarity index 98% rename from agent-pack/04-task-system/tasks/in_progress/task-041-add-semantic-cluster-view-to-frontend.md rename to agent-pack/04-task-system/tasks/pull_requested/task-041-add-semantic-cluster-view-to-frontend.md index 8243467..8296438 100644 --- a/agent-pack/04-task-system/tasks/in_progress/task-041-add-semantic-cluster-view-to-frontend.md +++ b/agent-pack/04-task-system/tasks/pull_requested/task-041-add-semantic-cluster-view-to-frontend.md @@ -2,7 +2,7 @@ id: task-041 title: Add Semantic Cluster View to Frontend type: task -status: in_progress +status: pull_requested priority: P1 owner: Eric Lott claim_owner: Eric Lott