## Agents Added - Alateya: R&D, biotech, innovations - Clan (Spirit): Community spirit agent - Eonarch: Consciousness evolution agent ## Changes - docker-compose.node1.yml: Added tokens for all 3 new agents - gateway-bot/http_api.py: Added configs and webhook endpoints - gateway-bot/clan_prompt.txt: New prompt file - gateway-bot/eonarch_prompt.txt: New prompt file ## Fixes - Fixed ROUTER_URL from :9102 to :8000 (internal container port) - All 9 Telegram agents now working ## Documentation - Created PROJECT-MASTER-INDEX.md - single entry point - Added various status documents and scripts Tokens configured: - Helion, NUTRA, Agromatrix (existing) - Alateya, Clan, Eonarch (new) - Druid, GreenFood, DAARWIZZ (configured)
286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
# 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))
|