Skip to content

Commit 4050656

Browse files
committed
Implement block action registry and analysis panel with in-place component edits
1 parent d75c60b commit 4050656

20 files changed

Lines changed: 1614 additions & 151 deletions

backend/lambda_handler.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
48935143
def 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":

backend/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ class Session:
9898
is_hidden: bool = False
9999
# OpenAI File Search vector store reference
100100
openai_vector_store_id: Optional[str] = None
101+
# Non-block component analyses (panel data)
102+
analysis_records: List[Dict[str, Any]] = field(default_factory=list)
101103

102104
def add_user_message(self, content: str, attachments: Optional[List[Dict[str, Any]]] = None) -> None:
103105
msg: Dict[str, Any] = {"role": "user", "content": content}
@@ -200,6 +202,7 @@ def to_dict(self) -> Dict[str, Any]:
200202
"usage": self.usage if isinstance(self.usage, dict) else _default_usage_payload(),
201203
"is_hidden": self.is_hidden,
202204
"openai_vector_store_id": self.openai_vector_store_id,
205+
"analysis_records": self.analysis_records if isinstance(self.analysis_records, list) else [],
203206
}
204207

205208
@classmethod
@@ -237,6 +240,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "Session":
237240
usage = _default_usage_payload()
238241
if not isinstance(usage, dict):
239242
usage = _default_usage_payload()
243+
analysis_records = data.get("analysis_records", [])
244+
if isinstance(analysis_records, str):
245+
try:
246+
analysis_records = json.loads(analysis_records)
247+
except Exception:
248+
analysis_records = []
249+
if not isinstance(analysis_records, list):
250+
analysis_records = []
240251

241252
return cls(
242253
id=data.get("id", str(uuid.uuid4())),
@@ -250,6 +261,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "Session":
250261
usage=usage,
251262
is_hidden=bool(data.get("is_hidden", False)),
252263
openai_vector_store_id=data.get("openai_vector_store_id"),
264+
analysis_records=analysis_records,
253265
)
254266

255267

@@ -529,6 +541,9 @@ def get(self, session_id: str) -> Optional[Session]:
529541
usage_payload = self._json_loads_or_default(item.get("usage"), _default_usage_payload())
530542
if not isinstance(usage_payload, dict):
531543
usage_payload = _default_usage_payload()
544+
analysis_records_payload = self._json_loads_or_default(item.get("analysis_records"), [])
545+
if not isinstance(analysis_records_payload, list):
546+
analysis_records_payload = []
532547
session = Session(
533548
id=item.get("id", session_id),
534549
messages=[],
@@ -542,6 +557,7 @@ def get(self, session_id: str) -> Optional[Session]:
542557
usage=usage_payload,
543558
is_hidden=item.get("is_hidden", False),
544559
openai_vector_store_id=(item.get("openai_vector_store_id") or None),
560+
analysis_records=analysis_records_payload,
545561
)
546562

547563
# Re-hydrate system prompt as first system message (if present).
@@ -628,6 +644,7 @@ def save(self, session: Session) -> None:
628644
"usage": json.dumps(session.usage if isinstance(session.usage, dict) else _default_usage_payload()),
629645
"is_hidden": session.is_hidden,
630646
"openai_vector_store_id": session.openai_vector_store_id or "",
647+
"analysis_records": json.dumps(session.analysis_records if isinstance(session.analysis_records, list) else []),
631648
}
632649

633650
self.table.put_item(Item=db_item)

backend/template.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,18 @@ Resources:
212212
Path: /session
213213
Method: DELETE
214214
RestApiId: !Ref FPAIApi
215+
AnalyzeSessionComponent:
216+
Type: Api
217+
Properties:
218+
Path: /session/analysis
219+
Method: POST
220+
RestApiId: !Ref FPAIApi
221+
ListSessionComponentAnalysis:
222+
Type: Api
223+
Properties:
224+
Path: /session/analysis
225+
Method: GET
226+
RestApiId: !Ref FPAIApi
215227
CreateSessionShare:
216228
Type: Api
217229
Properties:

0 commit comments

Comments
 (0)