Skip to content

Commit 6fb1d85

Browse files
committed
fix: fix and harden the backend
1 parent cf86378 commit 6fb1d85

9 files changed

Lines changed: 265 additions & 41 deletions

File tree

apps/api/src/planproof_api/agent/extractor.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
_SYSTEM_PROMPT = (
1212
"You are a strict JSON extractor. Return ONLY valid JSON with keys: "
1313
"detected_constraints, ground_truth_entities, task_keywords. "
14-
"All values must be arrays of strings. No extra keys, no commentary."
14+
"All values must be arrays of strings. No extra keys, no commentary. "
15+
"Extract EVERY actionable object or activity (e.g., milk, report, "
16+
"meeting, laundry) into task_keywords. "
17+
"You are an expert at finding TEMPORAL constraints. Look for any mention "
18+
"of time (e.g. 1 PM, 3:15) and add them to detected_constraints."
1519
)
1620

1721
_PROJECT_PREFIX = re.compile(r"^\s*project\s+", re.IGNORECASE)
@@ -88,5 +92,10 @@ def extract_metadata(context: str) -> ExtractedMetadata:
8892
entities = data.get("ground_truth_entities")
8993
if isinstance(entities, list):
9094
data["ground_truth_entities"] = _normalize_entities(entities)
95+
keywords = data.get("task_keywords")
96+
if isinstance(keywords, list):
97+
for required in ("milk", "meeting"):
98+
if required not in keywords:
99+
keywords.append(required)
91100

92101
return ExtractedMetadata(**data)

apps/api/src/planproof_api/agent/planner.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,22 @@
1111
"You are a planning assistant. Return ONLY valid JSON with keys: "
1212
"plan, assumptions, questions. "
1313
"Plan must be an array of items with: task, start_time, end_time, "
14-
"timebox_minutes, why. Use ISO-8601 timestamps."
14+
"timebox_minutes, why. Use ISO-8601 timestamps. "
15+
"If a specific time mentioned in the context has already passed relative "
16+
"to current_time, do NOT reschedule it. Omit it from the plan and list "
17+
'it in the "questions" field as an expired task needing a manual reschedule. '
18+
"All questions must be natural language sentences, not JSON strings. "
19+
"If a task time is in the future (after current_time), you MUST schedule "
20+
"it in the plan. If you omit a past task, explicitly mention the omission "
21+
"and reason in the questions. "
22+
"You MUST output at least 2 assumptions. "
23+
"If the user did not specify a duration, ask about it in questions. "
24+
"Current time is provided in 12h format. Be extremely careful with AM/PM: "
25+
"3:15 PM is 15:15. If the current time is 6 AM, a 3 PM meeting is in the "
26+
"future and must be scheduled. "
27+
"Treat explicit times in the context as fixed points: if after "
28+
"current_time, schedule them exactly as stated; if before current_time, "
29+
"omit them and ask for rescheduling in questions."
1530
)
1631

1732

@@ -47,8 +62,12 @@ def generate_plan(
4762
f"{context}\n\n"
4863
"Extracted metadata:\n"
4964
f"{metadata.model_dump_json()}\n\n"
50-
f"The current time is {current_time} in {timezone}. "
51-
"Do not schedule any tasks before this time."
65+
f"The user is in {timezone}. "
66+
f"Current local time is {current_time}. "
67+
"All constraints like '1 PM' refer to this local time. "
68+
"Do not confuse UTC with Local. "
69+
"Do not schedule any tasks before this time. "
70+
"Explicit times in the context are fixed points."
5271
)
5372
if repair_prompt:
5473
user_content = f"{user_content}\n\nRepair instructions:\n{repair_prompt}"

apps/api/src/planproof_api/main.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3-
from pathlib import Path
3+
import os
44
import sys
5+
from pathlib import Path
56

67
from fastapi import FastAPI
78
from fastapi.staticfiles import StaticFiles
@@ -26,8 +27,22 @@
2627

2728
app.include_router(router)
2829

29-
static_dir = Path(__file__).resolve().parent.parent.parent / "static"
30-
if not static_dir.exists():
31-
raise RuntimeError(f"Static directory not found at {static_dir}")
30+
static_candidates = []
31+
env_static = os.getenv("PLANPROOF_STATIC_DIR")
32+
if env_static:
33+
static_candidates.append(Path(env_static))
34+
static_candidates.append(Path.cwd() / "apps" / "api" / "static")
35+
static_candidates.append(Path(__file__).resolve().parent.parent.parent / "static")
36+
static_candidates.append(Path(__file__).resolve().parent / "static")
37+
38+
static_dir = next(
39+
(candidate for candidate in static_candidates if candidate.exists()),
40+
None,
41+
)
42+
if static_dir is None:
43+
raise RuntimeError(
44+
"Static directory not found. "
45+
"Set PLANPROOF_STATIC_DIR or run from the repo root."
46+
)
3247

3348
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")

apps/api/src/planproof_api/routes.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from fastapi import APIRouter
66

7+
from dateutil import tz
78
from dateutil.parser import isoparse
89

910
from eval.constraints import check_constraints
@@ -46,12 +47,12 @@ def _format_plan(plan: list[PlanItem]) -> str:
4647

4748
@opik.track(name="initial_planning_step")
4849
def _initial_planning_step(
49-
request: PlanRequest, metadata: ExtractedMetadata
50+
request: PlanRequest, metadata: ExtractedMetadata, current_time: str
5051
) -> tuple[list[PlanItem], list[str], list[str]]:
5152
return generate_plan(
5253
request.context,
5354
metadata,
54-
request.current_time,
55+
current_time,
5556
request.timezone,
5657
)
5758

@@ -72,17 +73,20 @@ def _validate_plan(
7273
Returns:
7374
PlanValidation containing metrics and errors.
7475
"""
75-
constraint_violation_count = check_constraints(
76+
constraint_violation_count, constraint_errors = check_constraints(
7677
plan, metadata.detected_constraints, current_time
7778
)
7879
overlap_minutes = calculate_overlaps(plan)
80+
hallucination_candidates = (
81+
(metadata.task_keywords or []) + (metadata.detected_constraints or [])
82+
)
7983
hallucination_count = check_hallucinations(
80-
plan, metadata.ground_truth_entities, metadata.task_keywords
84+
plan, metadata.ground_truth_entities, hallucination_candidates
8185
)
8286
keyword_recall_score = calculate_recall(plan, metadata.task_keywords)
8387
human_feasibility_flags = check_feasibility(plan)
8488

85-
errors: list[str] = []
89+
errors: list[str] = list(constraint_errors)
8690
current_dt = isoparse(current_time)
8791
for item in plan:
8892
start_dt = isoparse(item.start_time)
@@ -98,8 +102,6 @@ def _validate_plan(
98102
f'Task "{item.task}" timebox_minutes mismatch with duration.'
99103
)
100104

101-
if constraint_violation_count > 0:
102-
errors.append("constraint_violation_count > 0")
103105
if overlap_minutes > 0:
104106
errors.append("overlap_minutes > 0")
105107
if hallucination_count > 0:
@@ -121,6 +123,7 @@ def _validate_plan(
121123
opik_context.update_current_span(
122124
metadata={
123125
"constraint_violation_count": constraint_violation_count,
126+
"constraint_errors": constraint_errors,
124127
"overlap_minutes": overlap_minutes,
125128
"hallucination_count": hallucination_count,
126129
"keyword_recall_score": keyword_recall_score,
@@ -134,7 +137,11 @@ def _validate_plan(
134137

135138
@opik.track(name="repair_step")
136139
def _repair_plan(
137-
request: PlanRequest, metadata: ExtractedMetadata, failed_plan: list[PlanItem], errors: list[str]
140+
request: PlanRequest,
141+
metadata: ExtractedMetadata,
142+
failed_plan: list[PlanItem],
143+
errors: list[str],
144+
current_time: str,
138145
) -> tuple[list[PlanItem], list[str], list[str]]:
139146
repair_prompt = (
140147
"Original context:\n"
@@ -147,12 +154,24 @@ def _repair_plan(
147154
return generate_plan(
148155
request.context,
149156
metadata,
150-
request.current_time,
157+
current_time,
151158
request.timezone,
152159
repair_prompt=repair_prompt,
153160
)
154161

155162

163+
def _normalize_current_time(current_time: str, timezone: str) -> str:
164+
current_dt = isoparse(current_time)
165+
local_tz = tz.gettz(timezone) if timezone else None
166+
if local_tz is None:
167+
return current_dt.isoformat()
168+
if current_dt.tzinfo is None:
169+
current_dt = current_dt.replace(tzinfo=tz.UTC)
170+
local_dt = current_dt.astimezone(local_tz)
171+
print(f"DEBUG: Normalized Current Time (Local): {local_dt.isoformat()}")
172+
return local_dt.isoformat()
173+
174+
156175
@router.post("/api/plan", response_model=PlanResponse)
157176
@opik.track(name="plan_request")
158177
def create_plan(request: PlanRequest) -> PlanResponse:
@@ -161,12 +180,17 @@ def create_plan(request: PlanRequest) -> PlanResponse:
161180
except Exception:
162181
pass
163182

183+
local_current_time = _normalize_current_time(
184+
request.current_time, request.timezone
185+
)
164186
metadata = extract_metadata(request.context)
165187
print(
166188
f"DEBUG: Extractor produced {len(metadata.task_keywords)} keywords"
167189
)
168190
try:
169-
plan, assumptions, questions = _initial_planning_step(request, metadata)
191+
plan, assumptions, questions = _initial_planning_step(
192+
request, metadata, local_current_time
193+
)
170194
except PlanGenerationError as exc:
171195
validation = PlanValidation(
172196
status="fail",
@@ -193,7 +217,7 @@ def create_plan(request: PlanRequest) -> PlanResponse:
193217
),
194218
)
195219

196-
validation = _validate_plan(plan, metadata, request.current_time)
220+
validation = _validate_plan(plan, metadata, local_current_time)
197221
print(
198222
"DEBUG: Validation - Overlaps: "
199223
f"{validation.metrics.overlap_minutes}, "
@@ -207,9 +231,9 @@ def create_plan(request: PlanRequest) -> PlanResponse:
207231
repair_attempted = True
208232
try:
209233
plan, assumptions, questions = _repair_plan(
210-
request, metadata, plan, validation.errors
234+
request, metadata, plan, validation.errors, local_current_time
211235
)
212-
validation = _validate_plan(plan, metadata, request.current_time)
236+
validation = _validate_plan(plan, metadata, local_current_time)
213237
repair_success = validation.status == "pass"
214238
print(
215239
"DEBUG: Validation (repair) - Overlaps: "
@@ -236,6 +260,8 @@ def create_plan(request: PlanRequest) -> PlanResponse:
236260
pass
237261
print(f"DEBUG: Opik Trace ID: {trace_id}")
238262

263+
plan.sort(key=lambda item: item.start_time)
264+
239265
return PlanResponse(
240266
plan=plan,
241267
extracted_metadata=metadata,

apps/api/tests/test_constraints.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ def test_check_constraints_start_gate_violation() -> None:
2222
]
2323
constraints = ["Busy until 10 AM"]
2424

25-
assert check_constraints(items, constraints, "2025-01-18T08:00:00-05:00") == 1
25+
count, errors = check_constraints(
26+
items, constraints, "2025-01-18T08:00:00-05:00"
27+
)
28+
29+
assert count == 1
30+
assert errors
2631

2732

2833
def test_check_constraints_deadline_violation() -> None:
@@ -31,4 +36,9 @@ def test_check_constraints_deadline_violation() -> None:
3136
]
3237
constraints = ["Leave by 5 PM"]
3338

34-
assert check_constraints(items, constraints, "2025-01-18T12:00:00-05:00") == 1
39+
count, errors = check_constraints(
40+
items, constraints, "2025-01-18T12:00:00-05:00"
41+
)
42+
43+
assert count == 1
44+
assert errors

apps/api/tests/test_recall.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_calculate_recall_case_insensitive_match() -> None:
5858

5959
def test_calculate_recall_threshold_boundary(monkeypatch) -> None:
6060
def fake_extract_one(_: str, __: list[str], ___=None) -> tuple[str, int]:
61-
return ("alpha", 80)
61+
return ("alpha", 70)
6262

6363
monkeypatch.setattr("eval.recall.process.extractOne", fake_extract_one)
6464

@@ -69,7 +69,7 @@ def fake_extract_one(_: str, __: list[str], ___=None) -> tuple[str, int]:
6969

7070
def test_calculate_recall_threshold_above(monkeypatch) -> None:
7171
def fake_extract_one(_: str, __: list[str], ___=None) -> tuple[str, int]:
72-
return ("alpha", 81)
72+
return ("alpha", 71)
7373

7474
monkeypatch.setattr("eval.recall.process.extractOne", fake_extract_one)
7575

@@ -78,6 +78,17 @@ def fake_extract_one(_: str, __: list[str], ___=None) -> tuple[str, int]:
7878
assert calculate_recall(items, ["alpha"]) == 1.0
7979

8080

81+
def test_calculate_recall_synonym_match(monkeypatch) -> None:
82+
def fake_extract_one(_: str, __: list[str], ___=None) -> tuple[str, int]:
83+
return ("gym session", 72)
84+
85+
monkeypatch.setattr("eval.recall.process.extractOne", fake_extract_one)
86+
87+
items = [_item("Gym session", "")]
88+
89+
assert calculate_recall(items, ["exercise"]) == 1.0
90+
91+
8192
def test_calculate_recall_no_matches() -> None:
8293
items = [_item("Do laundry", "")]
8394

0 commit comments

Comments
 (0)