diff --git a/app/src/main/java/com/example/iaso/ConvexApiHelper.java b/app/src/main/java/com/example/iaso/ConvexApiHelper.java index 7292e05..5fb529f 100644 --- a/app/src/main/java/com/example/iaso/ConvexApiHelper.java +++ b/app/src/main/java/com/example/iaso/ConvexApiHelper.java @@ -2,6 +2,7 @@ import android.os.Handler; import android.os.Looper; +import android.util.Log; import org.json.JSONArray; import org.json.JSONException; @@ -11,6 +12,8 @@ import java.util.ArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -22,8 +25,15 @@ public class ConvexApiHelper { // ==================== CONFIGURATION ==================== + private static final String TAG = "ConvexApiHelper"; private static final String CONVEX_URL = "https://neighborly-chihuahua-847.convex.cloud"; private static final String CONVEX_ACTION_PATH = "workhorse:getClaudeResponse"; + private static final int DEFAULT_DAILY_MINUTES = 30; + private static final int DEFAULT_TOTAL_DAYS = 30; + private static final Pattern DAILY_TIME_PATTERN = + Pattern.compile("Daily\\s*time:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); + private static final Pattern TOTAL_DAYS_PATTERN = + Pattern.compile("Total\\s*days:\\s*(\\d+)", Pattern.CASE_INSENSITIVE); // ==================== SETUP ==================== @@ -54,10 +64,54 @@ public ConvexApiHelper() { * Do NOT remove until workhorse.java is fully migrated */ public void sendMessageToClaude(String userMessage, ClaudeResponseCallback callback) { + int dailyMinutes = extractFirstInt(userMessage, DAILY_TIME_PATTERN, DEFAULT_DAILY_MINUTES, "dailyMinutes"); + int totalDays = extractFirstInt(userMessage, TOTAL_DAYS_PATTERN, DEFAULT_TOTAL_DAYS, "totalDays"); + sendMessageToClaude(null, userMessage, dailyMinutes, totalDays, callback); + } + + /** + * Matrix-specific overload that forwards to the current Convex action schema + * without a custom system prompt. + */ + public void sendMessageToClaude( + String userMessage, + int dailyMinutes, + int totalDays, + ClaudeResponseCallback callback) { + sendMessageToClaude(null, userMessage, dailyMinutes, totalDays, callback); + } + + /** + * Matrix-specific overload that forwards to the current Convex action schema: + * args = { messages, context } where messages contains only the user message + * and context includes timeline params. A custom system prompt can be passed + * through to Convex for prompt-control use cases (e.g. Matrix JSON schema). + */ + public void sendMessageToClaude( + String systemPrompt, + String userMessage, + int dailyMinutes, + int totalDays, + ClaudeResponseCallback callback) { executor.execute(() -> { try { + JSONArray messagesArray = new JSONArray(); + JSONObject userMsg = new JSONObject(); + userMsg.put("role", "user"); + userMsg.put("content", userMessage); + messagesArray.put(userMsg); + + JSONObject contextObj = new JSONObject(); + contextObj.put("dailyMinutes", dailyMinutes); + contextObj.put("targetDays", totalDays); + contextObj.put("phase", "generating"); + JSONObject argsObject = new JSONObject(); - argsObject.put("userMessage", userMessage); + argsObject.put("messages", messagesArray); + argsObject.put("context", contextObj); + if (systemPrompt != null && !systemPrompt.trim().isEmpty()) { + argsObject.put("systemPrompt", systemPrompt); + } JSONObject requestJson = new JSONObject(); requestJson.put("path", CONVEX_ACTION_PATH); @@ -81,18 +135,16 @@ public void sendMessageToClaude(String userMessage, ClaudeResponseCallback callb String status = jsonResponse.optString("status", ""); if ("success".equals(status)) { - String claudeResponse = jsonResponse.optString("value", ""); - postSuccess(callback, claudeResponse); + postSuccess(callback, jsonResponse.optString("value", "")); } else { - String errorMessage = jsonResponse.optString("errorMessage", "Unknown error from Convex"); - postError(callback, "Convex error: " + errorMessage); + postError(callback, "Convex error: " + jsonResponse.optString("errorMessage", "Unknown error")); } } } catch (IOException e) { postError(callback, "Network error: " + e.getMessage()); } catch (JSONException e) { - postError(callback, "Error parsing response: " + e.getMessage()); + postError(callback, "Error building request: " + e.getMessage()); } }); } @@ -182,7 +234,23 @@ private void postError(ClaudeResponseCallback callback, String errorMessage) { mainHandler.post(() -> callback.onError(errorMessage)); } + /** + * Extracts the first integer captured by the provided regex pattern. + * Returns fallback when no match is found or parsing fails. + */ + private int extractFirstInt(String input, Pattern pattern, int fallback, String fieldName) { + Matcher matcher = pattern.matcher(input); + if (matcher.find()) { + try { + return Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException ignored) { + Log.w(TAG, "Failed parsing " + fieldName + " from input, using default " + fallback); + } + } + return fallback; + } + public void shutdown() { executor.shutdown(); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/iaso/matrix/MatrixEngine.java b/app/src/main/java/com/example/iaso/matrix/MatrixEngine.java new file mode 100644 index 0000000..432c6ae --- /dev/null +++ b/app/src/main/java/com/example/iaso/matrix/MatrixEngine.java @@ -0,0 +1,281 @@ +package com.example.iaso.matrix; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.example.iaso.ConvexApiHelper; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MatrixEngine { + + private static final String TAG = "MatrixEngine"; + + // ─── Callback ──────────────────────────────────────────────────────────── + + public interface MatrixEngineCallback { + void onTimelineGenerated(@NonNull MatrixGoal goal); + void onError(@NonNull String errorMessage); + } + + // ─── Public API ────────────────────────────────────────────────────────── + + public void generateTimeline( + @NonNull Context context, + @NonNull String goalDescription, + int dailyMinutes, + int totalDays, + @NonNull MatrixEngineCallback callback + ) { + // System prompt — fixed rules, never changes + String system = MatrixPrompts.TIMELINE_SYSTEM; + + // User message — just the goal data, changes every call + String userTemplate = MatrixPrompts.TIMELINE_USER + .replace("{goalDescription}", "%s") + .replace("{dailyMinutes}", "%s") + .replace("{totalDays}", "%s"); + String user = String.format( + userTemplate, + goalDescription, + String.valueOf(dailyMinutes), + String.valueOf(totalDays) + ); + + Log.d(TAG, "Generating timeline request: dailyMinutes=" + dailyMinutes + + ", totalDays=" + totalDays + + ", promptLength=" + (system.length() + user.length())); + + ConvexApiHelper api = new ConvexApiHelper(); + api.sendMessageToClaude(system, user, dailyMinutes, totalDays, new ConvexApiHelper.ClaudeResponseCallback() { + + @Override + public void onSuccess(String response) { + Log.d(TAG, "Received timeline response: responseLength=" + + (response != null ? response.length() : 0)); + try { + List milestones = parseMilestones(response); + + if (milestones.isEmpty()) { + callback.onError("No milestones could be parsed from the response."); + return; + } + + // Validate day sum and normalize to totalDays when possible + int sum = 0; + for (MatrixMilestone m : milestones) sum += m.getAllocatedDays(); + if (sum != totalDays) { + Log.w(TAG, "Day sum mismatch: expected " + totalDays + ", got " + sum); + boolean normalized = normalizeMilestoneDays(milestones, totalDays); + if (!normalized) { + callback.onError("Failed to normalize milestones to requested total days."); + return; + } + } + + MatrixGoal goal = buildGoal(goalDescription, totalDays, milestones); + MatrixStorage.saveGoal(context, goal); + + MatrixSnapshot snapshot = new MatrixSnapshot(); + snapshot.setActiveGoalId(goal.getGoalId()); + snapshot.setActiveMilestoneIndex(0); + snapshot.setCurrentDayInMilestone(1); + snapshot.setTotalDaysElapsed(0); + snapshot.setTotalDaysRemaining(totalDays); + snapshot.setBufferRemaining(0); + MatrixStorage.saveSnapshot(context, snapshot); + + callback.onTimelineGenerated(goal); + + } catch (Exception e) { + Log.e(TAG, "Parse/build error: " + e.getMessage(), e); + callback.onError("Failed to build timeline: " + e.getMessage()); + } finally { + api.shutdown(); + } + } + + @Override + public void onError(String errorMessage) { + try { + Log.e(TAG, "ConvexApiHelper error: " + errorMessage); + callback.onError(errorMessage); + } finally { + api.shutdown(); + } + } + }); + } + + // ─── Parsing ───────────────────────────────────────────────────────────── + + /** + * 3-layer parse fallback: + * 1) Strict JSON array + * 2) Bracket extraction (handles markdown fences or preamble) + * 3) Regex object-by-object extraction (last resort) + */ + private List parseMilestones(String raw) throws JSONException { + String trimmed = raw.trim(); + + // 1) Strict JSON array + try { + JSONArray array = new JSONArray(trimmed); + List milestones = buildMilestonesFromArray(array); + if (!milestones.isEmpty()) { + Log.d(TAG, "Parsed via strict JSON path."); + return milestones; + } + } catch (JSONException ignored) { } + + // 2) Bracket extraction + int start = trimmed.indexOf('['); + int end = trimmed.lastIndexOf(']'); + if (start != -1 && end > start) { + String extracted = trimmed.substring(start, end + 1); + try { + JSONArray array = new JSONArray(extracted); + List milestones = buildMilestonesFromArray(array); + if (!milestones.isEmpty()) { + Log.d(TAG, "Parsed via bracket extraction."); + return milestones; + } + } catch (JSONException ignored) { } + } + + // 3) Regex fallback + Log.w(TAG, "Falling back to regex milestone extraction."); + return parseMilestonesViaRegex(trimmed); + } + + private List parseMilestonesViaRegex(String raw) throws JSONException { + Pattern blockPattern = Pattern.compile("\\{[^{}]*\"name\"[^{}]*\\}"); + Matcher matcher = blockPattern.matcher(raw); + + JSONArray array = new JSONArray(); + while (matcher.find()) { + try { + array.put(new JSONObject(matcher.group())); + } catch (JSONException ignored) { } + } + + if (array.length() == 0) { + Log.e(TAG, "Regex extraction found no milestone blocks."); + } + + return buildMilestonesFromArray(array); + } + + /** + * Converts a JSONArray of milestone objects into MatrixMilestone list. + * Assigns UUIDs, orderIndex, and computes startDay from cumulative allocatedDays. + */ + private List buildMilestonesFromArray(JSONArray array) throws JSONException { + List milestones = new ArrayList<>(); + int startDay = 1; + + for (int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + + String name = obj.optString("name", "Milestone " + (i + 1)); + String description = obj.optString("description", ""); + int allocatedDays = Math.max(1, obj.optInt("allocatedDays", 1)); + + // Parse dependencies — logged now, stored when MatrixMilestone supports it + JSONArray deps = obj.optJSONArray("dependencies"); + if (deps != null && deps.length() > 0) { + Log.d(TAG, "Milestone " + i + " dependencies: " + deps.toString()); + } + + MatrixMilestone milestone = new MatrixMilestone(); + milestone.setMilestoneId(UUID.randomUUID().toString()); + milestone.setName(name); + milestone.setDescription(description); + milestone.setOrderIndex(i); + milestone.setAllocatedDays(allocatedDays); + milestone.setBufferDays(0); + milestone.setStartDay(startDay); + + milestones.add(milestone); + startDay += allocatedDays; + } + + return milestones; + } + + private boolean normalizeMilestoneDays(List milestones, int totalDays) { + int sum = 0; + for (MatrixMilestone milestone : milestones) { + sum += milestone.getAllocatedDays(); + } + + if (sum == totalDays) { + return true; + } + + if (sum < totalDays) { + MatrixMilestone last = milestones.get(milestones.size() - 1); + last.setAllocatedDays(last.getAllocatedDays() + (totalDays - sum)); + recomputeStartDays(milestones); + return true; + } + + int overflow = sum - totalDays; + for (int i = milestones.size() - 1; i >= 0 && overflow > 0; i--) { + MatrixMilestone milestone = milestones.get(i); + int reducible = milestone.getAllocatedDays() - 1; + if (reducible <= 0) { + continue; + } + + int reduction = Math.min(reducible, overflow); + milestone.setAllocatedDays(milestone.getAllocatedDays() - reduction); + overflow -= reduction; + } + + if (overflow > 0) { + return false; + } + + recomputeStartDays(milestones); + return true; + } + + private void recomputeStartDays(List milestones) { + int startDay = 1; + for (MatrixMilestone milestone : milestones) { + milestone.setStartDay(startDay); + startDay += milestone.getAllocatedDays(); + } + } + + // ─── Goal Assembly ─────────────────────────────────────────────────────── + + private MatrixGoal buildGoal(String goalDescription, int totalDays, List milestones) { + MatrixGoal goal = new MatrixGoal(); + String goalId = UUID.randomUUID().toString(); + goal.setGoalId(goalId); + goal.setHabitName(goalDescription); + goal.setGoalDescription(goalDescription); + + long now = System.currentTimeMillis(); + goal.setStartDate(now); + goal.setTotalDays(totalDays); + for (MatrixMilestone milestone : milestones) { + milestone.setParentGoalId(goalId); + } + goal.setMilestones(milestones); + + return goal; + } +} diff --git a/app/src/main/java/com/example/iaso/matrix/MatrixPrompts.java b/app/src/main/java/com/example/iaso/matrix/MatrixPrompts.java new file mode 100644 index 0000000..5738cfd --- /dev/null +++ b/app/src/main/java/com/example/iaso/matrix/MatrixPrompts.java @@ -0,0 +1,49 @@ +package com.example.iaso.matrix; + +public final class MatrixPrompts { + private MatrixPrompts() {} + + // ── SYSTEM PROMPT ──────────────────────────────────────────────────────── + // Rules, persona, schema — sent to the system field. + // Fixed. Never changes between calls. Claude reads this first + // and treats it as standing instructions for the session. + // ───────────────────────────────────────────────────────────────────────── + public static final String TIMELINE_SYSTEM = + "You are Monad, a milestone planning engine.\n\n" + + + "TASK:\n" + + "Decompose the given goal into an ordered list of milestones.\n" + + "Each milestone must be a concrete, completable phase of work.\n\n" + + + "OUTPUT RULES (CRITICAL):\n" + + "1. Reply with a raw JSON array ONLY. No markdown, no backticks, no explanation.\n" + + "2. The array must start with [ and end with ].\n" + + "3. Every allocatedDays value must be >= 1.\n" + + "4. The SUM of all allocatedDays must equal exactly the totalDays given.\n" + + "5. No gaps between milestones. Milestone N starts the day after N-1 ends.\n" + + "6. Milestones must be ordered: foundational first, dependent last.\n" + + "7. description must be a specific, measurable definition of done\n" + + " (e.g. 'Can perform 10 pull-ups with correct form', not 'Get stronger').\n" + + "8. dependencies lists the zero-based indices of milestones that must\n" + + " be completed before this one. First milestone always has [].\n\n" + + + "SCHEMA (each element):\n" + + "{\n" + + " \"name\": \"Short milestone title\",\n" + + " \"description\": \"Measurable definition of done\",\n" + + " \"allocatedDays\": = 1>,\n" + + " \"dependencies\": []\n" + + "}\n\n" + + + "Begin your response with [ and nothing else."; + + // ── USER MESSAGE ───────────────────────────────────────────────────────── + // Just the goal data — sent to the user message field. + // Changes every call. Placeholders replaced at runtime in MatrixEngine. + // ───────────────────────────────────────────────────────────────────────── + public static final String TIMELINE_USER = + "Goal: {goalDescription}\n" + + "Daily time: {dailyMinutes} minutes/day\n" + + "Total days: {totalDays}"; +} + diff --git a/convex/workhorse.ts b/convex/workhorse.ts index 348b886..41416e0 100644 --- a/convex/workhorse.ts +++ b/convex/workhorse.ts @@ -148,6 +148,7 @@ function extractGoalSummary( export const getClaudeResponse = action({ args: { + systemPrompt: v.optional(v.string()), messages: v.array( v.object({ role: v.union(v.literal("user"), v.literal("assistant")), @@ -169,13 +170,15 @@ export const getClaudeResponse = action({ // Select system prompt based on current phase const systemPrompt = - args.context.phase === "refining" - ? buildRefiningPrompt(args.context.dailyMinutes, args.context.targetDays) - : buildGeneratingPrompt( - args.context.dailyMinutes, - args.context.targetDays, - extractGoalSummary(args.messages) - ); + args.systemPrompt && args.systemPrompt.trim().length > 0 + ? args.systemPrompt + : args.context.phase === "refining" + ? buildRefiningPrompt(args.context.dailyMinutes, args.context.targetDays) + : buildGeneratingPrompt( + args.context.dailyMinutes, + args.context.targetDays, + extractGoalSummary(args.messages) + ); const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", @@ -205,4 +208,4 @@ export const getClaudeResponse = action({ throw new Error("Unexpected response format from Claude API"); }, -}); \ No newline at end of file +});