@@ -238,6 +238,63 @@ class MessageResponse(BaseModel):
238238 metadata : Optional [Dict [str , Any ]] = None
239239
240240
241+ class CreateGoalRequest (BaseModel ):
242+ """Create or replace a finance research goal."""
243+
244+ objective : str = Field (..., min_length = 1 , max_length = 5000 )
245+ criteria : List [str ] = Field (default_factory = list )
246+ ui_summary : str = ""
247+ protocol : str = "thesis_review"
248+ risk_tier : str = "research_general"
249+ token_budget : Optional [int ] = Field (None , ge = 1 )
250+ turn_budget : Optional [int ] = Field (None , ge = 1 )
251+ time_budget_seconds : Optional [int ] = Field (None , ge = 1 )
252+
253+
254+ class AddGoalEvidenceRequest (BaseModel ):
255+ """Append evidence to a finance research goal."""
256+
257+ goal_id : str = Field (..., min_length = 1 )
258+ expected_goal_id : str = Field (..., min_length = 1 )
259+ text : str = Field (..., min_length = 1 , max_length = 10000 )
260+ criterion_id : Optional [str ] = None
261+ claim_id : Optional [str ] = None
262+ evidence_type : str = "evidence"
263+ tool_call_id : Optional [str ] = None
264+ run_id : Optional [str ] = None
265+ source_provider : Optional [str ] = None
266+ source_type : Optional [str ] = None
267+ source_uri : Optional [str ] = None
268+ symbol_universe : List [str ] = Field (default_factory = list )
269+ benchmark : List [str ] = Field (default_factory = list )
270+ timeframe : Optional [str ] = None
271+ method : Optional [str ] = None
272+ assumptions : Dict [str , Any ] = Field (default_factory = dict )
273+ artifact_path : Optional [str ] = None
274+ artifact_hash : Optional [str ] = None
275+ data_as_of : Optional [str ] = None
276+ confidence : Optional [str ] = None
277+ caveat : Optional [str ] = None
278+ contradicts_claim_ids : List [str ] = Field (default_factory = list )
279+
280+
281+ class GoalSnapshotResponse (BaseModel ):
282+ """Finance research goal snapshot."""
283+
284+ goal : Dict [str , Any ]
285+ claims : List [Dict [str , Any ]]
286+ criteria : List [Dict [str , Any ]]
287+ evidence : List [Dict [str , Any ]]
288+ evidence_count : int = 0
289+
290+
291+ class AddGoalEvidenceResponse (BaseModel ):
292+ """Response after appending goal evidence."""
293+
294+ evidence : Dict [str , Any ]
295+ snapshot : GoalSnapshotResponse
296+
297+
241298
242299# ============================================================================
243300# FastAPI Application
@@ -1320,6 +1377,7 @@ async def api_info():
13201377# ============================================================================
13211378
13221379_session_service = None
1380+ _goal_store = None
13231381
13241382
13251383def _get_session_service ():
@@ -1353,6 +1411,26 @@ def _get_session_service():
13531411 return _session_service
13541412
13551413
1414+ def _get_goal_store ():
1415+ """Return the shared finance goal store."""
1416+ global _goal_store
1417+ if _goal_store is None :
1418+ from src .goal import GoalStore
1419+
1420+ _goal_store = GoalStore ()
1421+ return _goal_store
1422+
1423+
1424+ def _get_existing_session_or_404 (session_id : str ):
1425+ svc = _get_session_service ()
1426+ if not svc :
1427+ raise HTTPException (status_code = 501 , detail = "Session runtime not enabled" )
1428+ session = svc .get_session (session_id )
1429+ if not session :
1430+ raise HTTPException (status_code = 404 , detail = f"Session { session_id } not found" )
1431+ return svc , session
1432+
1433+
13561434@app .post ("/sessions" , response_model = SessionResponse , status_code = status .HTTP_201_CREATED , dependencies = [Depends (require_auth )])
13571435async def create_session (request : CreateSessionRequest ):
13581436 """Create a chat session."""
@@ -1410,6 +1488,124 @@ async def get_session(session_id: str):
14101488 )
14111489
14121490
1491+ @app .post (
1492+ "/sessions/{session_id}/goal" ,
1493+ response_model = GoalSnapshotResponse ,
1494+ status_code = status .HTTP_201_CREATED ,
1495+ dependencies = [Depends (require_auth )],
1496+ )
1497+ async def create_session_goal (session_id : str , req : CreateGoalRequest ):
1498+ """Create or replace the current finance research goal for a session."""
1499+ _validate_path_param (session_id , "session_id" )
1500+ svc , _session = _get_existing_session_or_404 (session_id )
1501+ from src .goal import RiskTier
1502+
1503+ criteria = [item .strip () for item in req .criteria if item .strip ()]
1504+ if not criteria :
1505+ criteria = ["Define research thesis" , "Record at least one supporting or contradicting evidence row" ]
1506+ try :
1507+ risk_tier = RiskTier (req .risk_tier )
1508+ except ValueError as exc :
1509+ raise HTTPException (status_code = 400 , detail = f"invalid risk_tier: { req .risk_tier } " ) from exc
1510+ if risk_tier is RiskTier .LIVE_TRADING_OR_EXECUTION :
1511+ raise HTTPException (status_code = 400 , detail = "live trading or execution goals are not supported" )
1512+
1513+ goal_store = _get_goal_store ()
1514+ try :
1515+ goal = goal_store .replace_goal (
1516+ session_id = session_id ,
1517+ objective = req .objective ,
1518+ criteria = criteria ,
1519+ ui_summary = req .ui_summary ,
1520+ source = "api" ,
1521+ protocol = req .protocol ,
1522+ risk_tier = risk_tier ,
1523+ token_budget = req .token_budget ,
1524+ turn_budget = req .turn_budget ,
1525+ time_budget_seconds = req .time_budget_seconds ,
1526+ )
1527+ except ValueError as exc :
1528+ raise HTTPException (status_code = 400 , detail = str (exc )) from exc
1529+ snapshot = goal_store .get_goal_snapshot (goal .goal_id )
1530+ if snapshot is None :
1531+ raise HTTPException (status_code = 500 , detail = "Goal created but could not be reloaded" )
1532+ svc .event_bus .emit (session_id , "goal.created" , {"goal" : snapshot ["goal" ]})
1533+ return snapshot
1534+
1535+
1536+ @app .get (
1537+ "/sessions/{session_id}/goal" ,
1538+ response_model = GoalSnapshotResponse ,
1539+ dependencies = [Depends (require_auth )],
1540+ )
1541+ async def get_session_goal (session_id : str ):
1542+ """Return the current finance research goal snapshot for a session."""
1543+ _validate_path_param (session_id , "session_id" )
1544+ _get_existing_session_or_404 (session_id )
1545+ snapshot = _get_goal_store ().get_current_snapshot (session_id )
1546+ if snapshot is None :
1547+ raise HTTPException (status_code = 404 , detail = "No current goal" )
1548+ return snapshot
1549+
1550+
1551+ @app .post (
1552+ "/sessions/{session_id}/goal/evidence" ,
1553+ response_model = AddGoalEvidenceResponse ,
1554+ status_code = status .HTTP_201_CREATED ,
1555+ dependencies = [Depends (require_auth )],
1556+ )
1557+ async def add_session_goal_evidence (session_id : str , req : AddGoalEvidenceRequest ):
1558+ """Append traceable evidence to the current finance research goal."""
1559+ _validate_path_param (session_id , "session_id" )
1560+ svc , _session = _get_existing_session_or_404 (session_id )
1561+ from dataclasses import asdict
1562+ from src .goal import EvidenceInput , StaleGoalError
1563+
1564+ goal_store = _get_goal_store ()
1565+ try :
1566+ evidence = goal_store .append_evidence (
1567+ session_id = session_id ,
1568+ goal_id = req .goal_id ,
1569+ expected_goal_id = req .expected_goal_id ,
1570+ evidence = EvidenceInput (
1571+ criterion_id = req .criterion_id ,
1572+ claim_id = req .claim_id ,
1573+ evidence_type = req .evidence_type ,
1574+ text = req .text ,
1575+ tool_call_id = req .tool_call_id ,
1576+ run_id = req .run_id ,
1577+ source_provider = req .source_provider ,
1578+ source_type = req .source_type ,
1579+ source_uri = req .source_uri ,
1580+ symbol_universe = req .symbol_universe ,
1581+ benchmark = req .benchmark ,
1582+ timeframe = req .timeframe ,
1583+ method = req .method ,
1584+ assumptions = req .assumptions ,
1585+ artifact_path = req .artifact_path ,
1586+ artifact_hash = req .artifact_hash ,
1587+ data_as_of = req .data_as_of ,
1588+ confidence = req .confidence ,
1589+ caveat = req .caveat ,
1590+ contradicts_claim_ids = req .contradicts_claim_ids ,
1591+ ),
1592+ )
1593+ except StaleGoalError as exc :
1594+ raise HTTPException (status_code = 409 , detail = str (exc )) from exc
1595+ except ValueError as exc :
1596+ raise HTTPException (status_code = 400 , detail = str (exc )) from exc
1597+
1598+ snapshot = goal_store .get_goal_snapshot (req .goal_id )
1599+ if snapshot is None :
1600+ raise HTTPException (status_code = 500 , detail = "Goal snapshot could not be reloaded" )
1601+ svc .event_bus .emit (
1602+ session_id ,
1603+ "goal.evidence" ,
1604+ {"evidence" : asdict (evidence ), "goal_id" : req .goal_id },
1605+ )
1606+ return {"evidence" : asdict (evidence ), "snapshot" : snapshot }
1607+
1608+
14131609@app .delete ("/sessions/{session_id}" , dependencies = [Depends (require_auth )])
14141610async def delete_session (session_id : str ):
14151611 """Delete a session."""
@@ -1420,6 +1616,7 @@ async def delete_session(session_id: str):
14201616 deleted = svc .delete_session (session_id )
14211617 if not deleted :
14221618 raise HTTPException (status_code = 404 , detail = f"Session { session_id } not found" )
1619+ _get_goal_store ().delete_session_goals (session_id )
14231620 return {"status" : "deleted" , "session_id" : session_id }
14241621
14251622
@@ -1504,6 +1701,7 @@ async def session_events(
15041701 session_id : str ,
15051702 request : Request ,
15061703 last_event_id : Optional [str ] = Query (None , alias = "Last-Event-ID" ),
1704+ replay : Optional [str ] = Query (None ),
15071705):
15081706 """SSE stream for agent events."""
15091707 _validate_path_param (session_id , "session_id" )
@@ -1516,9 +1714,19 @@ async def session_events(
15161714
15171715 header_id = request .headers .get ("Last-Event-ID" )
15181716 event_id = header_id or last_event_id
1717+ replay_active = (replay or "" ).lower () == "active"
1718+ replay_all = False
1719+ if replay_active and not event_id and session .last_attempt_id :
1720+ attempt = svc .store .get_attempt (session_id , session .last_attempt_id )
1721+ attempt_status = getattr (attempt .status , "value" , attempt .status ) if attempt else None
1722+ replay_all = attempt_status == "running"
15191723
15201724 async def event_generator ():
1521- async for event in svc .event_bus .subscribe (session_id , last_event_id = event_id ):
1725+ async for event in svc .event_bus .subscribe (
1726+ session_id ,
1727+ last_event_id = event_id ,
1728+ replay_all = replay_all ,
1729+ ):
15221730 if await request .is_disconnected ():
15231731 break
15241732 yield event .to_sse ()
@@ -1594,20 +1802,19 @@ async def upload_file(file: UploadFile):
15941802 if not file .filename :
15951803 raise HTTPException (status_code = 400 , detail = "Missing filename" )
15961804 filename = Path (file .filename ).name
1597- ext = Path (file . filename ).suffix .lower ()
1805+ ext = Path (filename ).suffix .lower ()
15981806 if ext in _BLOCKED_UPLOAD_EXT or filename .lower () in _BLOCKED_UPLOAD_NAMES :
15991807 raise HTTPException (
16001808 status_code = 400 ,
16011809 detail = "This file type is not allowed for upload." ,
16021810 )
16031811
1604- UPLOADS_DIR .mkdir (parents = True , exist_ok = True )
1605-
16061812 safe_name = f"{ uuid .uuid4 ().hex } { ext } "
16071813 dest = UPLOADS_DIR / safe_name
16081814 total_size = 0
16091815
16101816 try :
1817+ UPLOADS_DIR .mkdir (parents = True , exist_ok = True )
16111818 with dest .open ("wb" ) as handle :
16121819 while True :
16131820 chunk = await file .read (_UPLOAD_CHUNK_SIZE )
@@ -1628,14 +1835,17 @@ async def upload_file(file: UploadFile):
16281835 except OSError as exc :
16291836 if dest .exists ():
16301837 dest .unlink ()
1631- raise HTTPException (status_code = 500 , detail = f"Failed to store upload: { exc } " ) from exc
1838+ raise HTTPException (
1839+ status_code = 500 ,
1840+ detail = "Upload failed while storing the file. Please retry or choose a different file." ,
1841+ ) from exc
16321842 finally :
16331843 await file .close ()
16341844
16351845 return {
16361846 "status" : "ok" ,
1637- "file_path" : str ( dest . resolve ()) ,
1638- "filename" : file . filename ,
1847+ "file_path" : f"uploads/ { safe_name } " ,
1848+ "filename" : filename ,
16391849 }
16401850
16411851
0 commit comments