Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 209 additions & 17 deletions backend/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import secrets
import httpx
from pydantic import BaseModel, Field
from typing import List, Optional, Literal
from typing import Any, List, Optional, Literal
from datetime import datetime, timezone, timedelta

from llm_provider import run_llm, run_llm_cheap
Expand Down Expand Up @@ -148,7 +148,7 @@ async def _check_diagnosis_gate(user_id: str, idea_id: str):
{"idea_id": idea_id, "user_id": user_id}, {"_id": 0}, sort=[("created_at", -1)]
)
if latest_diag:
actions = latest_diag.get("this_week_actions", [])
actions: List[dict[str, Any]] = list(latest_diag.get("this_week_actions") or [])
if not any(a.get("done") for a in actions):
raise HTTPException(
status_code=400,
Expand Down Expand Up @@ -367,6 +367,13 @@ class CheckinCreate(BaseModel):
blockers: str = ""


class CheckinUpdate(BaseModel):
actions_completed: List[str] = []
what_changed: str = ""
new_learnings: str = ""
blockers: str = ""


class Action(BaseModel):
action_id: str
title: str
Expand Down Expand Up @@ -404,10 +411,10 @@ class Checkin(BaseModel):
checkin_id: str
idea_id: str
user_id: str
actions_completed: List[str]
what_changed: str
new_learnings: str
blockers: str
diagnosis_id: Optional[str] = None
delta_summary: str = "" # AI-generated "what changed since last week"
created_at: datetime

Expand Down Expand Up @@ -484,6 +491,26 @@ def _serialize_timeline_doc(doc: Optional[dict]) -> Optional[dict]:
)


def _serialize_checkin(doc: dict) -> dict:
v = doc.get("created_at")
if isinstance(v, str):
try:
doc["created_at"] = datetime.fromisoformat(v)
except Exception:
pass
return _add_ist_fields(doc, ["created_at"])


def _build_action_snapshot(
diag: Optional[dict[str, Any]], completed_action_ids: List[str]
) -> List[dict[str, Any]]:
actions: List[dict[str, Any]] = []
completed = set(completed_action_ids or [])
for action in (diag or {}).get("this_week_actions", []):
actions.append({**action, "done": action.get("action_id") in completed})
return actions


@api.post("/ideas")
async def create_idea(
payload: IdeaCreate,
Expand Down Expand Up @@ -710,7 +737,11 @@ async def _parse_or_repair_json(raw: str, session_id: str) -> dict:


def _build_idea_brief(
user: User, idea: dict, prior_diag: Optional[dict], last_checkin: Optional[dict]
user: User,
idea: dict,
prior_diag: Optional[dict],
last_checkin: Optional[dict],
last_checkin_diag: Optional[dict],
) -> str:
lines = [
f"FOUNDER: {user.name}",
Expand All @@ -733,10 +764,18 @@ def _build_idea_brief(
f"- Previous key insight: {prior_diag.get('key_insight')}",
]
if last_checkin:
completed_actions = []
pending_actions = []
for action in (last_checkin_diag or {}).get("this_week_actions") or []:
if action.get("done"):
completed_actions.append(action.get("title"))
else:
pending_actions.append(action.get("title"))
lines += [
"",
"LAST CHECK-IN:",
f"- Actions completed: {last_checkin.get('actions_completed')}",
f"- Actions completed: {completed_actions or '(none)'}",
f"- Actions not completed: {pending_actions or '(none)'}",
f"- What changed: {last_checkin.get('what_changed')}",
f"- New learnings: {last_checkin.get('new_learnings')}",
f"- Blockers: {last_checkin.get('blockers')}",
Expand Down Expand Up @@ -815,7 +854,17 @@ async def _run_diagnosis_job(job_id: str, idea_id: str, user_id: str):
last_checkin = await db.checkins.find_one(
{"idea_id": idea_id}, {"_id": 0}, sort=[("created_at", -1)]
)
brief = _build_idea_brief(user, idea, prior, last_checkin)
last_checkin_diag = None
if last_checkin and last_checkin.get("diagnosis_id"):
last_checkin_diag = await db.diagnoses.find_one(
{
"idea_id": idea_id,
"user_id": user_id,
"diagnosis_id": last_checkin["diagnosis_id"],
},
{"_id": 0},
)
brief = _build_idea_brief(user, idea, prior, last_checkin, last_checkin_diag)

parsed = await asyncio.wait_for(
_run_diagnosis_llm(brief, f"diag_{idea_id}_{uuid.uuid4().hex[:8]}"),
Expand Down Expand Up @@ -910,7 +959,7 @@ async def toggle_action(
)
if not diag:
raise HTTPException(404, "No diagnosis")
actions = diag.get("this_week_actions", [])
actions: List[dict[str, Any]] = list(diag.get("this_week_actions") or [])
for a in actions:
if a.get("action_id") == action_id:
a["done"] = not a.get("done", False)
Expand Down Expand Up @@ -957,16 +1006,27 @@ async def create_checkin(
streak = 1

checkin_id = f"cki_{uuid.uuid4().hex[:12]}"
# find the most recent diagnosis that does NOT yet have a linked checkin
latest_diag = None
async for d in db.diagnoses.find({"idea_id": idea_id}).sort("created_at", -1):
# check if any checkin already links to this diagnosis
exists = await db.checkins.find_one(
{"diagnosis_id": d.get("diagnosis_id")}, {"_id": 1}
)
if not exists:
latest_diag = d
break
doc = {
"checkin_id": checkin_id,
"idea_id": idea_id,
"user_id": user.user_id,
"actions_completed": payload.actions_completed,
"what_changed": payload.what_changed,
"new_learnings": payload.new_learnings,
"blockers": payload.blockers,
"delta_summary": "",
"created_at": now.isoformat(),
# keep the check-in record self-contained in the checkins collection
"diagnosis_id": latest_diag.get("diagnosis_id") if latest_diag else None,
}
await db.checkins.insert_one({**doc})
await db.ideas.update_one(
Expand All @@ -979,21 +1039,153 @@ async def create_checkin(
}
},
)
# Also mark completed actions as done in latest diagnosis
# Also mark completed actions as done in latest diagnosis (if present)
if latest_diag:
actions = _build_action_snapshot(latest_diag, payload.actions_completed)
await db.diagnoses.update_one(
{"diagnosis_id": latest_diag["diagnosis_id"]},
{"$set": {"this_week_actions": actions}},
)
return _serialize_timeline_doc({**doc, "streak": streak})


@api.get("/ideas/{idea_id}/checkins")
async def list_checkins(
idea_id: str,
request: Request,
limit: int = 20,
before: Optional[str] = None,
session_token_cookie: Optional[str] = Cookie(None, alias="session_token"),
authorization: Optional[str] = Header(None),
):
user = await get_current_user(request, session_token_cookie, authorization)
idea = await db.ideas.find_one(
{"idea_id": idea_id, "user_id": user.user_id, "status": {"$ne": "archived"}},
{"_id": 0, "idea_id": 1},
)
if not idea:
raise HTTPException(404, "Idea not found")

limit = max(1, min(limit, 100))
query: dict[str, Any] = {"idea_id": idea_id, "user_id": user.user_id}
if before:
query["created_at"] = {"$lt": before}

docs = (
await db.checkins.find(query, {"_id": 0})
.sort("created_at", -1)
.to_list(limit + 1)
)
has_more = len(docs) > limit
items = docs[:limit]
next_before = items[-1].get("created_at") if has_more and items else None

enriched_items = []
for item in items:
enriched = dict(item)
if not enriched.get("diagnosis_actions") and item.get("diagnosis_id"):
diag = await db.diagnoses.find_one(
{"idea_id": idea_id, "user_id": user.user_id, "diagnosis_id": item["diagnosis_id"]},
{"_id": 0, "this_week_actions": 1},
)
enriched["diagnosis_actions"] = diag.get("this_week_actions", []) if diag else []
else:
enriched["diagnosis_actions"] = enriched.get("diagnosis_actions", [])
enriched_items.append(enriched)

return {
"items": [_serialize_checkin(i) for i in enriched_items],
"has_more": has_more,
"next_before": next_before,
}


@api.patch("/ideas/{idea_id}/checkins/{checkin_id}")
async def update_checkin(
idea_id: str,
checkin_id: str,
payload: CheckinUpdate,
request: Request,
session_token_cookie: Optional[str] = Cookie(None, alias="session_token"),
authorization: Optional[str] = Header(None),
):
user = await get_current_user(request, session_token_cookie, authorization)
idea = await db.ideas.find_one(
{"idea_id": idea_id, "user_id": user.user_id, "status": {"$ne": "archived"}},
{"_id": 0, "idea_id": 1},
)
if not idea:
raise HTTPException(404, "Idea not found")

latest = await db.checkins.find_one(
{"idea_id": idea_id, "user_id": user.user_id},
{"_id": 0, "checkin_id": 1},
sort=[("created_at", -1)],
)
if not latest:
raise HTTPException(404, "Check-in not found")
if latest.get("checkin_id") != checkin_id:
raise HTTPException(
status_code=400,
detail="Only the latest check-in can be edited.",
)

existing = await db.checkins.find_one(
{"idea_id": idea_id, "user_id": user.user_id, "checkin_id": checkin_id},
{"_id": 0},
)
if not existing:
raise HTTPException(404, "Check-in not found")

latest_diag = await db.diagnoses.find_one(
{"idea_id": idea_id}, {"_id": 0}, sort=[("created_at", -1)]
{"idea_id": idea_id, "user_id": user.user_id},
{"_id": 0, "diagnosis_id": 1, "this_week_actions": 1},
sort=[("created_at", -1)],
)
if existing.get("diagnosis_id") != (latest_diag or {}).get("diagnosis_id"):
raise HTTPException(
status_code=400,
detail="Only the check-in for the latest diagnosis can be edited.",
)

updated = {
"what_changed": payload.what_changed,
"new_learnings": payload.new_learnings,
"blockers": payload.blockers,
}
linked_diag = None
if existing.get("diagnosis_id"):
linked_diag = await db.diagnoses.find_one(
{
"idea_id": idea_id,
"user_id": user.user_id,
"diagnosis_id": existing["diagnosis_id"],
},
{"_id": 0, "this_week_actions": 1},
)
if not linked_diag:
linked_diag = await db.diagnoses.find_one(
{"idea_id": idea_id, "user_id": user.user_id},
{"_id": 0, "this_week_actions": 1},
sort=[("created_at", -1)],
)

await db.checkins.update_one(
{"checkin_id": checkin_id, "idea_id": idea_id, "user_id": user.user_id},
{
"$set": updated
},
)

if latest_diag:
actions = latest_diag.get("this_week_actions", [])
completed = set(payload.actions_completed or [])
for a in actions:
if a.get("action_id") in completed:
a["done"] = True
actions = _build_action_snapshot(latest_diag, payload.actions_completed)
await db.diagnoses.update_one(
{"diagnosis_id": latest_diag["diagnosis_id"]},
{"$set": {"this_week_actions": actions}},
)
return _serialize_timeline_doc({**doc, "streak": streak})

out = {**existing, **updated}
return _serialize_checkin(out)


@api.get("/dashboard/summary")
Expand Down Expand Up @@ -1168,7 +1360,7 @@ async def build_shared_diagnosis_content(slug: str) -> dict:
"why": a.get("why"),
"effort": a.get("effort"),
}
for a in diag.get("this_week_actions", [])
for a in list(diag.get("this_week_actions") or [])
],
"created_at": diag["created_at"],
"created_at_ist": _to_ist_iso(diag["created_at"]),
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@ export async function createCheckin(idea_id, payload) {
return data;
}

export async function listCheckins(idea_id, params = {}) {
const { data } = await api.get(`/ideas/${idea_id}/checkins`, { params });
return data;
}

export async function updateCheckin(idea_id, checkin_id, payload) {
const { data } = await api.patch(
`/ideas/${idea_id}/checkins/${checkin_id}`,
payload,
);
return data;
}

export async function deleteIdea(idea_id) {
const { data } = await api.delete(`/ideas/${idea_id}`);
return data;
Expand Down
Loading
Loading