# Org Chat Logging & Decision Extraction Endpoints # To be appended to memory-service/app/main.py import re from typing import Optional, List from pydantic import BaseModel from datetime import datetime, date # ============================================================================ # ORG CHAT LOGGING # ============================================================================ class OrgChatMessageCreate(BaseModel): chat_id: int chat_type: str # official_ops, mentor_room, public_community chat_title: Optional[str] = None message_id: int sender_telegram_id: Optional[int] = None sender_account_id: Optional[str] = None # UUID if linked sender_username: Optional[str] = None sender_display_name: Optional[str] = None text: Optional[str] = None has_media: bool = False media_type: Optional[str] = None reply_to_message_id: Optional[int] = None message_at: datetime class DecisionRecord(BaseModel): decision: str action: Optional[str] = None owner: Optional[str] = None due_date: Optional[date] = None canon_change: bool = False # Decision extraction patterns DECISION_PATTERNS = { 'decision': re.compile(r'DECISION:\s*(.+?)(?=\n[A-Z]+:|$)', re.IGNORECASE | re.DOTALL), 'action': re.compile(r'ACTION:\s*(.+?)(?=\n[A-Z]+:|$)', re.IGNORECASE | re.DOTALL), 'owner': re.compile(r'OWNER:\s*(@?\w+)', re.IGNORECASE), 'due': re.compile(r'DUE:\s*(\d{4}-\d{2}-\d{2}|\d{2}\.\d{2}\.\d{4})', re.IGNORECASE), 'canon_change': re.compile(r'CANON_CHANGE:\s*(yes|true|так|1)', re.IGNORECASE), } def extract_decision_from_text(text: str) -> Optional[DecisionRecord]: """Extract structured decision from message text.""" if not text: return None # Check if this looks like a decision if 'DECISION:' not in text.upper(): return None decision_match = DECISION_PATTERNS['decision'].search(text) if not decision_match: return None decision_text = decision_match.group(1).strip() action_match = DECISION_PATTERNS['action'].search(text) owner_match = DECISION_PATTERNS['owner'].search(text) due_match = DECISION_PATTERNS['due'].search(text) canon_match = DECISION_PATTERNS['canon_change'].search(text) due_date = None if due_match: date_str = due_match.group(1) try: if '-' in date_str: due_date = datetime.strptime(date_str, '%Y-%m-%d').date() else: due_date = datetime.strptime(date_str, '%d.%m.%Y').date() except ValueError: pass return DecisionRecord( decision=decision_text, action=action_match.group(1).strip() if action_match else None, owner=owner_match.group(1) if owner_match else None, due_date=due_date, canon_change=bool(canon_match) ) @app.post("/org-chat/message") async def log_org_chat_message(msg: OrgChatMessageCreate): """ Log a message from an organizational chat. Automatically extracts decisions if present. """ try: # Insert message await db.pool.execute( """ INSERT INTO org_chat_messages ( chat_id, chat_type, chat_title, message_id, sender_telegram_id, sender_account_id, sender_username, sender_display_name, text, has_media, media_type, reply_to_message_id, message_at ) VALUES ($1, $2, $3, $4, $5, $6::uuid, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (chat_id, message_id) DO UPDATE SET text = EXCLUDED.text, has_media = EXCLUDED.has_media """, msg.chat_id, msg.chat_type, msg.chat_title, msg.message_id, msg.sender_telegram_id, msg.sender_account_id, msg.sender_username, msg.sender_display_name, msg.text, msg.has_media, msg.media_type, msg.reply_to_message_id, msg.message_at ) # Try to extract decision decision = None if msg.text: decision = extract_decision_from_text(msg.text) if decision: await db.pool.execute( """ INSERT INTO decision_records ( chat_id, source_message_id, decision, action, owner, due_date, canon_change ) VALUES ($1, $2, $3, $4, $5, $6, $7) """, msg.chat_id, msg.message_id, decision.decision, decision.action, decision.owner, decision.due_date, decision.canon_change ) logger.info(f"Decision extracted from message {msg.message_id} in chat {msg.chat_id}") return { "success": True, "message_id": msg.message_id, "decision_extracted": decision is not None } except Exception as e: logger.error(f"Failed to log org chat message: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/org-chat/{chat_id}/messages") async def get_org_chat_messages( chat_id: int, limit: int = Query(default=50, le=200), since: Optional[datetime] = None ): """Get messages from an organizational chat.""" try: if since: rows = await db.pool.fetch( """ SELECT * FROM org_chat_messages WHERE chat_id = $1 AND message_at > $2 ORDER BY message_at DESC LIMIT $3 """, chat_id, since, limit ) else: rows = await db.pool.fetch( """ SELECT * FROM org_chat_messages WHERE chat_id = $1 ORDER BY message_at DESC LIMIT $2 """, chat_id, limit ) return {"messages": [dict(r) for r in rows], "count": len(rows)} except Exception as e: logger.error(f"Failed to get org chat messages: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/decisions") async def get_decisions( status: Optional[str] = None, chat_id: Optional[int] = None, canon_only: bool = False, overdue_only: bool = False, limit: int = Query(default=20, le=100) ): """Get decision records with filters.""" try: conditions = [] params = [] param_idx = 1 if status: conditions.append(f"status = ${param_idx}") params.append(status) param_idx += 1 if chat_id: conditions.append(f"chat_id = ${param_idx}") params.append(chat_id) param_idx += 1 if canon_only: conditions.append("canon_change = true") if overdue_only: conditions.append(f"due_date < ${param_idx} AND status NOT IN ('completed', 'cancelled')") params.append(date.today()) param_idx += 1 where_clause = " AND ".join(conditions) if conditions else "1=1" params.append(limit) rows = await db.pool.fetch( f""" SELECT dr.*, ocm.text as source_text, ocm.sender_username FROM decision_records dr LEFT JOIN org_chat_messages ocm ON dr.chat_id = ocm.chat_id AND dr.source_message_id = ocm.message_id WHERE {where_clause} ORDER BY dr.created_at DESC LIMIT ${param_idx} """, *params ) return {"decisions": [dict(r) for r in rows], "count": len(rows)} except Exception as e: logger.error(f"Failed to get decisions: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.patch("/decisions/{decision_id}/status") async def update_decision_status( decision_id: str, status: str, updated_by: Optional[str] = None ): """Update decision status (pending, in_progress, completed, cancelled).""" try: valid_statuses = ['pending', 'in_progress', 'completed', 'cancelled'] if status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") await db.pool.execute( """ UPDATE decision_records SET status = $1, status_updated_at = NOW(), status_updated_by = $2 WHERE id = $3::uuid """, status, updated_by, decision_id ) return {"success": True, "decision_id": decision_id, "new_status": status} except Exception as e: logger.error(f"Failed to update decision status: {e}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/decisions/summary") async def get_decisions_summary(): """Get summary of decisions by status.""" try: rows = await db.pool.fetch( """ SELECT status, COUNT(*) as count, COUNT(*) FILTER (WHERE due_date < CURRENT_DATE AND status NOT IN ('completed', 'cancelled')) as overdue FROM decision_records GROUP BY status """ ) summary = {r['status']: {'count': r['count'], 'overdue': r['overdue']} for r in rows} # Count canon changes canon_count = await db.pool.fetchval( "SELECT COUNT(*) FROM decision_records WHERE canon_change = true" ) return { "by_status": summary, "canon_changes": canon_count, "total": sum(s['count'] for s in summary.values()) } except Exception as e: logger.error(f"Failed to get decisions summary: {e}") raise HTTPException(status_code=500, detail=str(e))