@@ -171,6 +171,8 @@ def _allowed_model_hints() -> List[str]:
171171_MAX_COMPONENT_EDIT_FULL_BLOCK_CONTENT_CHARS = 16000
172172_MAX_COMPONENT_EDIT_FULL_BLOCK_STRUCTURED_CHARS = 12000
173173_MAX_COMPONENT_EDIT_LOCAL_CONTEXT_CHARS = 600
174+ _MAX_COMPONENT_ANALYSIS_INSTRUCTION_CHARS = 2000
175+ _MAX_COMPONENT_ANALYSIS_DATA_CHARS = 12000
174176
175177# Module-level CORS origin for the current request (safe: Lambda = 1 request per instance)
176178_current_cors_origin = ""
@@ -4890,6 +4892,254 @@ def _resolve_session_id(event):
48904892 return ""
48914893
48924894
4895+ def _build_component_analysis_id (
4896+ source_block_id : str ,
4897+ component_type : str ,
4898+ action : str ,
4899+ instruction : str ,
4900+ component_data_json : str ,
4901+ ) -> str :
4902+ digest = hashlib .sha1 (
4903+ f"{ source_block_id } |{ component_type } |{ action } |{ instruction } |{ component_data_json } " .encode ("utf-8" )
4904+ ).hexdigest ()
4905+ return f"analysis_{ digest [:20 ]} "
4906+
4907+
4908+ def _normalize_component_analysis_payload (raw_body : Any ) -> Dict [str , Any ]:
4909+ if not isinstance (raw_body , dict ):
4910+ return {}
4911+
4912+ session_id = _normalize_text (raw_body .get ("session_id" ), _MAX_COMPONENT_EDIT_FIELD_CHARS )
4913+ source_block_id = _normalize_text (raw_body .get ("source_block_id" ), _MAX_COMPONENT_EDIT_FIELD_CHARS )
4914+ component_type = _normalize_text (
4915+ raw_body .get ("component_type" ),
4916+ _MAX_COMPONENT_EDIT_FIELD_CHARS ,
4917+ ).lower ()
4918+ action = _normalize_text (raw_body .get ("action" ) or "analyze" , 40 ).lower ()
4919+ instruction = _normalize_text (
4920+ raw_body .get ("instruction" ) or "Analyze this component in context." ,
4921+ _MAX_COMPONENT_ANALYSIS_INSTRUCTION_CHARS ,
4922+ )
4923+ component_data = _serialize_component_edit_data (raw_body .get ("component_data" ))
4924+ model = _normalize_text (raw_body .get ("model" ), 80 )
4925+
4926+ normalized = {
4927+ "session_id" : session_id ,
4928+ "source_block_id" : source_block_id ,
4929+ "component_type" : component_type ,
4930+ "action" : action ,
4931+ "instruction" : instruction ,
4932+ "component_data" : component_data ,
4933+ }
4934+ if model :
4935+ normalized ["model" ] = model
4936+ return normalized
4937+
4938+
4939+ def handle_session_component_analysis (event ):
4940+ """
4941+ Analyze a component and persist analysis records in session metadata only.
4942+
4943+ POST /session/analysis
4944+ """
4945+ try :
4946+ body = json .loads (event .get ("body" , "{}" ))
4947+ except (TypeError , json .JSONDecodeError ):
4948+ return create_response (400 , {"error" : "Invalid JSON body" })
4949+
4950+ payload = _normalize_component_analysis_payload (body )
4951+ session_id = str (payload .get ("session_id" ) or "" ).strip ()
4952+ source_block_id = str (payload .get ("source_block_id" ) or "" ).strip ()
4953+ component_type = str (payload .get ("component_type" ) or "" ).strip ().lower ()
4954+ action = str (payload .get ("action" ) or "" ).strip ().lower ()
4955+ instruction = str (payload .get ("instruction" ) or "" ).strip ()
4956+ component_data_json = str (payload .get ("component_data" ) or "" )
4957+
4958+ if not session_id :
4959+ return create_response (400 , {"error" : "Missing 'session_id'" })
4960+ if not source_block_id :
4961+ return create_response (400 , {"error" : "Missing 'source_block_id'" })
4962+ if component_type not in {"table" , "graph" , "image" }:
4963+ return create_response (400 , {"error" : "component_type must be one of: table, graph, image" })
4964+ if action != "analyze" :
4965+ return create_response (400 , {"error" : "Only 'analyze' action is supported" })
4966+
4967+ auth_user = _get_authenticated_user (event )
4968+ session = session_store .get (session_id )
4969+ if not session or session .is_hidden :
4970+ return create_response (404 , {"success" : False , "error" : f"Session not found: { session_id } " })
4971+
4972+ ownership_error = _enforce_session_owner (session , auth_user )
4973+ if ownership_error :
4974+ return ownership_error
4975+
4976+ source_block = find_block_by_id (session .blocks , source_block_id )
4977+ if not source_block :
4978+ return create_response (404 , {"success" : False , "error" : f"Source block not found: { source_block_id } " })
4979+
4980+ analysis_id = _build_component_analysis_id (
4981+ source_block_id = source_block_id ,
4982+ component_type = component_type ,
4983+ action = action ,
4984+ instruction = instruction ,
4985+ component_data_json = component_data_json [:_MAX_COMPONENT_ANALYSIS_DATA_CHARS ],
4986+ )
4987+
4988+ existing_records = session .analysis_records if isinstance (getattr (session , "analysis_records" , None ), list ) else []
4989+ for record in existing_records :
4990+ if not isinstance (record , dict ):
4991+ continue
4992+ if str (record .get ("analysis_id" ) or "" ) != analysis_id :
4993+ continue
4994+ return create_response (
4995+ 200 ,
4996+ {
4997+ "success" : True ,
4998+ "analysis_id" : analysis_id ,
4999+ "source_block_id" : source_block_id ,
5000+ "component_type" : component_type ,
5001+ "action" : action ,
5002+ "content" : str (record .get ("content" ) or "" ),
5003+ "cached" : True ,
5004+ "updated_at" : str (record .get ("updated_at" ) or "" ),
5005+ },
5006+ )
5007+
5008+ model = str (payload .get ("model" ) or os .getenv ("OPENAI_MODEL" , "gpt-5.2" )).strip ()
5009+ if not _is_allowed_model (model ):
5010+ return create_response (400 , {
5011+ "error" : f"Model '{ model } ' is not allowed" ,
5012+ "allowed_models" : _allowed_model_hints (),
5013+ })
5014+
5015+ source_block_content = _trim_to_word_boundary (
5016+ str (source_block .content or "" ),
5017+ _MAX_COMPONENT_EDIT_FULL_BLOCK_CONTENT_CHARS ,
5018+ )
5019+ component_data_context = _trim_to_word_boundary (
5020+ component_data_json ,
5021+ _MAX_COMPONENT_ANALYSIS_DATA_CHARS ,
5022+ )
5023+
5024+ messages = [
5025+ {
5026+ "role" : "system" ,
5027+ "content" : (
5028+ "You are an analytical tutor. Explain the component clearly and concisely.\n "
5029+ "Focus on what the learner should notice, how to interpret it, and practical caveats.\n "
5030+ "Return plain markdown only."
5031+ ),
5032+ },
5033+ {
5034+ "role" : "user" ,
5035+ "content" : "\n " .join (
5036+ [
5037+ f"Component type: { component_type } " ,
5038+ f"Requested action: { action } " ,
5039+ f"Instruction: { instruction } " ,
5040+ "" ,
5041+ "Source block content:" ,
5042+ source_block_content or "No source content available." ,
5043+ "" ,
5044+ "Structured component data (JSON/text):" ,
5045+ component_data_context or "No structured component data provided." ,
5046+ ]
5047+ ),
5048+ },
5049+ ]
5050+
5051+ try :
5052+ response = llm_chat (
5053+ messages = messages ,
5054+ model = model ,
5055+ max_tokens = 1200 ,
5056+ include_reasoning = False ,
5057+ timeout = 45 ,
5058+ )
5059+ content = str ((response or {}).get ("content" ) or "" ).strip () or "No analysis generated."
5060+ except Exception as exc :
5061+ logger .error ("Component analysis failed: %s" , exc )
5062+ return create_response (500 , {"success" : False , "error" : "Failed to generate component analysis" })
5063+
5064+ updated_at = datetime .utcnow ().isoformat ()
5065+ analysis_record = {
5066+ "analysis_id" : analysis_id ,
5067+ "source_block_id" : source_block_id ,
5068+ "component_type" : component_type ,
5069+ "action" : action ,
5070+ "content" : content ,
5071+ "updated_at" : updated_at ,
5072+ }
5073+
5074+ session .analysis_records = [r for r in existing_records if isinstance (r , dict ) and str (r .get ("analysis_id" ) or "" ) != analysis_id ]
5075+ session .analysis_records .append (analysis_record )
5076+ if len (session .analysis_records ) > 500 :
5077+ session .analysis_records = session .analysis_records [- 500 :]
5078+ session .updated_at = updated_at
5079+ session_store .save (session )
5080+
5081+ return create_response (
5082+ 200 ,
5083+ {
5084+ "success" : True ,
5085+ "analysis_id" : analysis_id ,
5086+ "source_block_id" : source_block_id ,
5087+ "component_type" : component_type ,
5088+ "action" : action ,
5089+ "content" : content ,
5090+ "cached" : False ,
5091+ "updated_at" : updated_at ,
5092+ },
5093+ )
5094+
5095+
5096+ def handle_list_session_component_analysis (event ):
5097+ """
5098+ List stored component analysis records from session metadata.
5099+
5100+ GET /session/analysis?id=<session_id>&source_block_id=...&component_type=...&action=...
5101+ """
5102+ session_id = _resolve_session_id (event )
5103+ if not session_id :
5104+ return create_response (400 , {"error" : "Missing session id. Provide query parameter 'id' or body field 'session_id'." })
5105+
5106+ auth_user = _get_authenticated_user (event )
5107+ session = session_store .get (session_id )
5108+ if not session or session .is_hidden :
5109+ return create_response (404 , {"success" : False , "error" : f"Session not found: { session_id } " })
5110+
5111+ ownership_error = _enforce_session_owner (session , auth_user )
5112+ if ownership_error :
5113+ return ownership_error
5114+
5115+ params = event .get ("queryStringParameters" ) or {}
5116+ source_block_id = _normalize_text ((params or {}).get ("source_block_id" ), _MAX_COMPONENT_EDIT_FIELD_CHARS )
5117+ component_type = _normalize_text ((params or {}).get ("component_type" ), _MAX_COMPONENT_EDIT_FIELD_CHARS ).lower ()
5118+ action = _normalize_text ((params or {}).get ("action" ), 40 ).lower ()
5119+
5120+ records = session .analysis_records if isinstance (getattr (session , "analysis_records" , None ), list ) else []
5121+ filtered : List [Dict [str , Any ]] = []
5122+ for record in records :
5123+ if not isinstance (record , dict ):
5124+ continue
5125+ if source_block_id and str (record .get ("source_block_id" ) or "" ) != source_block_id :
5126+ continue
5127+ if component_type and str (record .get ("component_type" ) or "" ).lower () != component_type :
5128+ continue
5129+ if action and str (record .get ("action" ) or "" ).lower () != action :
5130+ continue
5131+ filtered .append (record )
5132+
5133+ return create_response (
5134+ 200 ,
5135+ {
5136+ "success" : True ,
5137+ "session_id" : session_id ,
5138+ "analysis_records" : filtered ,
5139+ },
5140+ )
5141+
5142+
48935143def handle_update_session (event ):
48945144 """
48955145 Update session metadata.
@@ -6797,6 +7047,10 @@ def handler(event, context):
67977047 return handle_get_document_download_url (event )
67987048 elif path == "/session" and method == "GET" :
67997049 return handle_session (event )
7050+ elif path == "/session/analysis" and method == "POST" :
7051+ return handle_session_component_analysis (event )
7052+ elif path == "/session/analysis" and method == "GET" :
7053+ return handle_list_session_component_analysis (event )
68007054 elif path == "/session" and method == "PUT" :
68017055 return handle_update_session (event )
68027056 elif path == "/session" and method == "DELETE" :
0 commit comments